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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||||
SMTP_SECURE: Joi.boolean().default(false),
|
SMTP_SECURE: Joi.boolean().default(false),
|
||||||
// Stripe Configuration (optional for development)
|
// 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_SECRET_KEY: Joi.string().optional(),
|
||||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
||||||
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||||
@ -188,6 +192,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
BlogModule,
|
BlogModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
|
TradeAssistantModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
LogsModule,
|
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