feat(api): module assistant commerce international

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:52 +02:00
parent d32eecd0bf
commit cdea263b3a
5 changed files with 736 additions and 0 deletions

View File

@ -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,
],

View File

@ -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,
});

View File

@ -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 {}

View File

@ -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<SubscriptionRepository>;
let quota: jest.Mocked<TradeQuotaPort>;
let ai: jest.Mocked<TradeAiPort>;
let retrieval: jest.Mocked<TradeRetrievalPort>;
let conversations: jest.Mocked<TradeConversationRepository>;
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);
});
});

View File

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