diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index d2cb4ed..9f66edf 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,3 +1,4 @@ +import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -76,6 +77,9 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'), SMTP_SECURE: Joi.boolean().default(false), // Stripe Configuration (optional for development) + OPENAI_API_KEY: Joi.string().allow('').optional(), + OPENAI_MODEL: Joi.string().default('gpt-4.1-mini'), + OPENAI_EMBEDDING_MODEL: Joi.string().default('text-embedding-3-small'), STRIPE_SECRET_KEY: Joi.string().optional(), STRIPE_WEBHOOK_SECRET: Joi.string().optional(), STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(), @@ -188,6 +192,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; AdminModule, BlogModule, SubscriptionsModule, + TradeAssistantModule, ApiKeysModule, LogsModule, ], diff --git a/apps/backend/src/application/trade-assistant/trade-assistant.controller.ts b/apps/backend/src/application/trade-assistant/trade-assistant.controller.ts new file mode 100644 index 0000000..025e1d1 --- /dev/null +++ b/apps/backend/src/application/trade-assistant/trade-assistant.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsString, IsUUID, Length } from 'class-validator'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { TradeActor, TradeAssistantService } from './trade-assistant.service'; + +const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value); + +export class AskTradeAssistantDto { + @Transform(trim) + @IsString() + @Length(1, 2000) + question: string; + + @IsIn(['fr', 'en']) + language: string = 'fr'; + + /** Absent : la question ouvre une nouvelle conversation. */ + @IsOptional() + @IsUUID() + conversationId?: string; +} + +export class RenameConversationDto { + @Transform(trim) + @IsString() + @Length(1, 60) + title: string; +} + +// The global JWT guard validates the active account. No paid-feature gate: +// Bronze users and all dashboard roles also have access. +@ApiTags('Trade assistant') +@ApiBearerAuth() +@Controller('trade-assistant') +export class TradeAssistantController { + constructor(private readonly service: TradeAssistantService) {} + + @Get('quota') + status(@CurrentUser() user: UserPayload) { + return this.service.status(actorOf(user)); + } + + @Get('conversations') + list(@CurrentUser() user: UserPayload) { + return this.service.list(user.id); + } + + @Get('conversations/:id') + messages(@CurrentUser() user: UserPayload, @Param('id', ParseUUIDPipe) id: string) { + return this.service.messages(user.id, id); + } + + @Patch('conversations/:id') + @HttpCode(204) + async rename( + @CurrentUser() user: UserPayload, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RenameConversationDto + ) { + await this.service.rename(user.id, id, dto.title); + } + + @Delete('conversations/:id') + @HttpCode(204) + async remove(@CurrentUser() user: UserPayload, @Param('id', ParseUUIDPipe) id: string) { + await this.service.remove(user.id, id); + } + + @Post('questions') + @HttpCode(200) + ask(@CurrentUser() user: UserPayload, @Body() dto: AskTradeAssistantDto) { + return this.service.ask(actorOf(user), dto.question, dto.language, dto.conversationId); + } +} + +/** + * L'offre effective depend du role : il vient de la session validee, jamais du + * corps de requete. + */ +const actorOf = (user: UserPayload): TradeActor => ({ + id: user.id, + organizationId: user.organizationId, + role: user.role, + email: user.email, +}); diff --git a/apps/backend/src/application/trade-assistant/trade-assistant.module.ts b/apps/backend/src/application/trade-assistant/trade-assistant.module.ts new file mode 100644 index 0000000..827929b --- /dev/null +++ b/apps/backend/src/application/trade-assistant/trade-assistant.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { + TRADE_AI, + TRADE_CONVERSATIONS, + TRADE_EMBEDDINGS, + TRADE_QUOTA, + TRADE_RETRIEVAL, +} from '@domain/ports/out/trade-assistant.port'; +import { OpenAiEmbeddingAdapter } from '@infrastructure/ai/openai-embedding.adapter'; +import { OpenAiTradeAdapter } from '@infrastructure/ai/openai-trade.adapter'; +import { WikiRetriever } from '@infrastructure/ai/wiki-retriever'; +import { TypeOrmTradeConversationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository'; +import { TypeOrmTradeQuotaRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { TradeAssistantController } from './trade-assistant.controller'; +import { TradeAssistantService } from './trade-assistant.service'; + +@Module({ + // repond mais n'agit jamais. + controllers: [TradeAssistantController], + providers: [ + TradeAssistantService, + { provide: TRADE_AI, useClass: OpenAiTradeAdapter }, + { provide: TRADE_EMBEDDINGS, useClass: OpenAiEmbeddingAdapter }, + { provide: TRADE_RETRIEVAL, useClass: WikiRetriever }, + { provide: TRADE_QUOTA, useClass: TypeOrmTradeQuotaRepository }, + { provide: TRADE_CONVERSATIONS, useClass: TypeOrmTradeConversationRepository }, + ], +}) +export class TradeAssistantModule {} diff --git a/apps/backend/src/application/trade-assistant/trade-assistant.service.spec.ts b/apps/backend/src/application/trade-assistant/trade-assistant.service.spec.ts new file mode 100644 index 0000000..72fd7ef --- /dev/null +++ b/apps/backend/src/application/trade-assistant/trade-assistant.service.spec.ts @@ -0,0 +1,364 @@ +import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { TradeAssistantService, truncateTitle } from './trade-assistant.service'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { + TradeAiPort, + TradeConversationRepository, + TradeMessage, + TradePassage, + TradeQuotaPort, + TradeRetrievalPort, +} from '@domain/ports/out/trade-assistant.port'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; +import { AskTradeAssistantDto } from './trade-assistant.controller'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +const answer = { text: 'Réponse', inputTokens: 100, outputTokens: 50 }; + +/** Compte courant : role sans privilege, offre portee par l'organisation. */ +const actor = { id: 'user', organizationId: 'org', role: 'MANAGER' }; +const admin = { ...actor, role: 'ADMIN' }; + +const passage = (topic: string, href: string): TradePassage => ({ + id: `fr:${topic}:0`, + title: topic, + section: 'Section', + href, + text: 'Extrait du wiki.', + score: 0.8, +}); + +const conversation = { + id: 'c1', + title: 'Question', + createdAt: '2026-09-05T10:00:00.000Z', + updatedAt: '2026-09-05T10:00:00.000Z', + messageCount: 0, +}; + +const message = (role: 'user' | 'assistant', content: string): TradeMessage => ({ + id: `${role}-1`, + role, + content, + sources: [], + actions: [], + createdAt: '2026-09-05T10:00:00.000Z', +}); + +describe('TradeAssistantService', () => { + let service: TradeAssistantService; + let subscriptions: jest.Mocked; + let quota: jest.Mocked; + let ai: jest.Mocked; + let retrieval: jest.Mocked; + let conversations: jest.Mocked; + + beforeEach(() => { + subscriptions = { + findByOrganizationId: jest.fn().mockResolvedValue(null), + save: jest.fn(), + findById: jest.fn(), + findByStripeSubscriptionId: jest.fn(), + findByStripeCustomerId: jest.fn(), + findAll: jest.fn(), + delete: jest.fn(), + }; + quota = { + get: jest + .fn() + .mockResolvedValue({ day: '2026-09-05', resetsAt: '2026-09-05T22:00:00.000Z', used: 0 }), + reserve: jest.fn().mockResolvedValue(true), + release: jest.fn().mockResolvedValue(undefined), + recordTokens: jest.fn().mockResolvedValue(undefined), + }; + ai = { + isAvailable: jest.fn().mockReturnValue(true), + answer: jest.fn().mockResolvedValue(answer), + }; + retrieval = { search: jest.fn().mockResolvedValue([]) }; + conversations = { + list: jest.fn().mockResolvedValue([conversation]), + create: jest.fn().mockResolvedValue(conversation), + find: jest.fn().mockResolvedValue(conversation), + messages: jest.fn().mockResolvedValue([]), + addMessage: jest + .fn() + .mockImplementation((_id, role: 'user' | 'assistant', content: string) => + Promise.resolve(message(role, content)) + ), + rename: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + }; + service = new TradeAssistantService(subscriptions, quota, ai, retrieval, conversations); + }); + + /* ---------------------------------------------------------------------- */ + /* Quota */ + /* ---------------------------------------------------------------------- */ + + const onPlan = (plan: SubscriptionPlanType) => + subscriptions.findByOrganizationId.mockResolvedValue( + Subscription.create({ + id: 's', + organizationId: 'org', + plan: SubscriptionPlan.fromString(plan), + }) + ); + + it.each<[SubscriptionPlanType, number]>([ + ['BRONZE', 3], + ['SILVER', 10], + ['GOLD', 15], + ['PLATINIUM', -1], + ])('enforces %s quota per user', async (plan, limit) => { + onPlan(plan); + const result = await service.ask(actor, 'Question', 'fr'); + expect(result.quota.limit).toBe(limit); + expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', limit); + expect(subscriptions.findByOrganizationId).toHaveBeenCalledWith('org'); + expect(quota.recordTokens).toHaveBeenCalledWith('user', '2026-09-05', answer); + }); + + it('never blocks Platinium, however many questions were already asked', async () => { + onPlan('PLATINIUM'); + quota.get.mockResolvedValue({ day: '2026-09-05', resetsAt: '', used: 4200 }); + + const status = await service.status(actor); + expect(status.unlimited).toBe(true); + expect(status.limit).toBe(-1); + // `remaining` ne vaut pas 0 : cela se lirait comme un quota epuise. + expect(status.remaining).toBe(-1); + + const result = await service.ask(actor, 'Q', 'fr'); + expect(result.mode).toBe('ai'); + expect(ai.answer).toHaveBeenCalled(); + }); + + it('still meters Platinium usage, for cost tracking', async () => { + onPlan('PLATINIUM'); + await service.ask(actor, 'Q', 'fr'); + + expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', -1); + expect(quota.recordTokens).toHaveBeenCalledWith('user', '2026-09-05', answer); + }); + + it('gives an ADMIN the Platinium quota its own interface already shows', async () => { + // L'apercu d'abonnement affiche « Platinium » a tout compte ADMIN. Sans + // cette regle, l'assistant lisait l'abonnement de l'organisation — Bronze — + // et n'accordait que trois questions a un utilisateur a qui le produit + // annonçait partout l'offre illimitee. + onPlan('BRONZE'); + + const status = await service.status(admin); + + expect(status.plan).toBe('PLATINIUM'); + expect(status.unlimited).toBe(true); + expect((await service.ask(admin, 'Q', 'fr')).mode).toBe('ai'); + }); + + it('keeps the organisation plan for every other role', async () => { + onPlan('BRONZE'); + expect((await service.status({ ...actor, role: 'MANAGER' })).plan).toBe('BRONZE'); + expect((await service.status({ ...actor, role: 'USER' })).plan).toBe('BRONZE'); + expect((await service.status({ ...actor, role: undefined })).plan).toBe('BRONZE'); + }); + + it('promotes an ADMIN even when the organisation subscription is inactive', async () => { + subscriptions.findByOrganizationId.mockResolvedValue({ + isActive: () => false, + plan: SubscriptionPlan.fromString('SILVER'), + } as never); + + expect((await service.status(actor)).plan).toBe('BRONZE'); + expect((await service.status(admin)).plan).toBe('PLATINIUM'); + }); + + it('falls back to the strictest plan when the stored plan is unknown', async () => { + // Une offre inconnue donnait `undefined`, puis « NaN/undefined » a l'ecran. + subscriptions.findByOrganizationId.mockResolvedValue({ + isActive: () => true, + plan: { value: 'LEGACY_TIER' }, + } as never); + + const status = await service.status(actor); + expect(status.limit).toBe(3); + expect(status.remaining).toBe(3); + expect(status.unlimited).toBe(false); + }); + + it('defaults an unsubscribed dashboard account to Bronze', async () => { + expect((await service.status(actor)).limit).toBe(3); + }); + + it('does not call OpenAI when quota is exhausted', async () => { + quota.get.mockResolvedValue({ day: '2026-09-05', resetsAt: '', used: 3 }); + expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('guided'); + expect(quota.reserve).not.toHaveBeenCalled(); + expect(ai.answer).not.toHaveBeenCalled(); + }); + + it('handles a concurrent request taking the last slot', async () => { + quota.reserve.mockResolvedValue(false); + expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('guided'); + expect(ai.answer).not.toHaveBeenCalled(); + }); + + it('does not consume quota without an API key', async () => { + ai.isAvailable.mockReturnValue(false); + expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('unavailable'); + expect(quota.reserve).not.toHaveBeenCalled(); + }); + + it('refunds provider failures on the original day', async () => { + ai.answer.mockRejectedValue(new Error('timeout')); + await expect(service.ask(actor, 'Q', 'fr')).rejects.toThrow(ServiceUnavailableException); + expect(quota.release).toHaveBeenCalledWith('user', '2026-09-05'); + expect(quota.recordTokens).not.toHaveBeenCalled(); + }); + + it('never refunds a successful answer on accounting failure', async () => { + quota.recordTokens.mockRejectedValue(new Error('database unavailable')); + expect((await service.ask(actor, 'Q', 'fr')).mode).toBe('ai'); + expect(quota.release).not.toHaveBeenCalled(); + }); + + it('returns a fresh quota when the answer crosses midnight', async () => { + quota.get + .mockResolvedValueOnce({ day: '2026-09-05', resetsAt: '', used: 0 }) + .mockResolvedValueOnce({ day: '2026-09-06', resetsAt: '', used: 0 }); + expect((await service.ask(actor, 'Q', 'fr')).quota.day).toBe('2026-09-06'); + expect(quota.reserve).toHaveBeenCalledWith('user', '2026-09-05', 3); + }); + + /* ---------------------------------------------------------------------- */ + /* Conversations */ + /* ---------------------------------------------------------------------- */ + + it('opens a conversation titled after the first question', async () => { + const result = await service.ask(actor, ' Quels documents pour un LCL ? ', 'fr'); + + expect(conversations.create).toHaveBeenCalledWith('user', 'Quels documents pour un LCL ?'); + expect(result.mode).toBe('ai'); + expect(result.conversationId).toBe('c1'); + expect(conversations.addMessage.mock.calls.map(call => call[1])).toEqual(['user', 'assistant']); + }); + + it('replays the existing turns when continuing a conversation', async () => { + conversations.messages.mockResolvedValue([ + message('user', 'Première question'), + message('assistant', 'Première réponse'), + ]); + + await service.ask(actor, 'Et pour le FCL ?', 'fr', 'c1'); + + expect(conversations.create).not.toHaveBeenCalled(); + expect(ai.answer).toHaveBeenCalledWith( + expect.objectContaining({ + question: 'Et pour le FCL ?', + history: [ + { role: 'user', content: 'Première question' }, + { role: 'assistant', content: 'Première réponse' }, + ], + }) + ); + }); + + it('rejects a conversation owned by someone else before spending a question', async () => { + conversations.find.mockResolvedValue(null); + + await expect(service.ask(actor, 'Q', 'fr', 'other')).rejects.toThrow(NotFoundException); + expect(quota.reserve).not.toHaveBeenCalled(); + expect(ai.answer).not.toHaveBeenCalled(); + }); + + it('does not leave an empty conversation behind when the provider fails', async () => { + ai.answer.mockRejectedValue(new Error('timeout')); + + await expect(service.ask(actor, 'Q', 'fr')).rejects.toThrow(ServiceUnavailableException); + expect(conversations.remove).toHaveBeenCalledWith('user', 'c1'); + }); + + it('keeps an existing conversation when the provider fails', async () => { + ai.answer.mockRejectedValue(new Error('timeout')); + + await expect(service.ask(actor, 'Q', 'fr', 'c1')).rejects.toThrow(ServiceUnavailableException); + expect(conversations.remove).not.toHaveBeenCalled(); + }); + + it.each(['messages', 'rename', 'remove'] as const)('guards %s by owner', async method => { + conversations.find.mockResolvedValue(null); + const call = + method === 'rename' + ? service.rename('user', 'c1', 'Titre') + : method === 'remove' + ? service.remove('user', 'c1') + : service.messages('user', 'c1'); + + await expect(call).rejects.toThrow(NotFoundException); + }); + + /* ---------------------------------------------------------------------- */ + /* Recherche documentaire */ + /* ---------------------------------------------------------------------- */ + + it('passes the retrieved passages to the model and cites each page once', async () => { + retrieval.search.mockResolvedValue([ + passage('Douanes', '/dashboard/wiki/douanes'), + passage('Douanes', '/dashboard/wiki/douanes'), + passage('Incoterms', '/dashboard/wiki/incoterms'), + ]); + + const result = await service.ask(actor, 'Code SH ?', 'fr'); + + expect(retrieval.search).toHaveBeenCalledWith('Code SH ?', 'fr'); + expect(ai.answer).toHaveBeenCalledWith( + expect.objectContaining({ passages: expect.arrayContaining([expect.any(Object)]) }) + ); + expect(result.sources).toEqual([ + { title: 'Douanes', section: 'Section', href: '/dashboard/wiki/douanes' }, + { title: 'Incoterms', section: 'Section', href: '/dashboard/wiki/incoterms' }, + ]); + }); + + it('still answers when the knowledge search fails', async () => { + retrieval.search.mockRejectedValue(new Error('redis down')); + + const result = await service.ask(actor, 'Q', 'fr'); + + expect(result.mode).toBe('ai'); + expect(ai.answer).toHaveBeenCalledWith(expect.objectContaining({ passages: [] })); + }); +}); + +describe('truncateTitle', () => { + it('keeps a short question untouched', () => { + expect(truncateTitle(' LCL ou FCL ? ')).toBe('LCL ou FCL ?'); + }); + + it('cuts long questions on a word boundary', () => { + const title = truncateTitle(`Quels documents ${'très '.repeat(20)}précisément ?`); + expect(title.length).toBeLessThanOrEqual(60); + expect(title).not.toMatch(/\s$/); + expect(title.endsWith('trè')).toBe(false); + }); +}); + +describe('AskTradeAssistantDto', () => { + it.each([' ', 'a'.repeat(2001), 42, null])('rejects invalid question %p', async question => { + const dto = plainToInstance(AskTradeAssistantDto, { question }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); + + it('accepts a trimmed question and default language', async () => { + const dto = plainToInstance(AskTradeAssistantDto, { question: ' LCL ? ' }); + expect(await validate(dto)).toEqual([]); + expect(dto.question).toBe('LCL ?'); + }); + + it('rejects a conversation id that is not a uuid', async () => { + const dto = plainToInstance(AskTradeAssistantDto, { question: 'Q', conversationId: 'nope' }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/backend/src/application/trade-assistant/trade-assistant.service.ts b/apps/backend/src/application/trade-assistant/trade-assistant.service.ts new file mode 100644 index 0000000..6550418 --- /dev/null +++ b/apps/backend/src/application/trade-assistant/trade-assistant.service.ts @@ -0,0 +1,239 @@ +import { + Inject, + Injectable, + Logger, + NotFoundException, + 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, +} 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'; + +/** + * 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 + ) {} + + 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); + + let answer; + try { + answer = await this.ai.answer({ question, language, history, passages }); + } 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), + }; + } + + /** + * 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(); +}