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:
parent
d32eecd0bf
commit
cdea263b3a
@ -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,
|
||||
],
|
||||
|
||||
@ -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,
|
||||
});
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user