diff --git a/apps/backend/src/domain/ports/out/trade-assistant.port.ts b/apps/backend/src/domain/ports/out/trade-assistant.port.ts new file mode 100644 index 0000000..098f3d4 --- /dev/null +++ b/apps/backend/src/domain/ports/out/trade-assistant.port.ts @@ -0,0 +1,160 @@ +export const TRADE_AI = 'TRADE_AI'; + +export interface TradeAnswer { + text: string; + inputTokens: number; + outputTokens: number; + /** Capacites reellement invoquees pour produire cette reponse. */ + actions?: TradeAction[]; +} + +/** Trace d'un appel d'outil, conservee avec le message et affichee a l'utilisateur. */ +export interface TradeAction { + name: string; + ok: boolean; +} + +/** + * Outil propose au modele. + * + * Le domaine ne connait ni OpenAI ni MCP : il decrit un nom, une phrase et un + * schema JSON. Chaque adaptateur traduit ensuite vers son propre format. + */ +export interface TradeToolDefinition { + name: string; + description: string; + parameters: Record; +} + +/** + * Execute un outil au nom de l'utilisateur courant. + * + * La fonction est fournie par la couche application, deja liee a l'identite de + * l'appelant : l'adaptateur ne peut pas choisir pour qui il agit. + */ +export type TradeToolInvoker = ( + name: string, + args: Record +) => Promise<{ ok: boolean; result: unknown }>; + +/** Un tour deja echange dans la conversation, envoye au modele comme contexte. */ +export interface TradeTurn { + role: 'user' | 'assistant'; + content: string; +} + +/** Un extrait du wiki retenu par la recherche, cite sous la reponse. */ +export interface TradePassage { + id: string; + /** Titre du sujet wiki, ex. « Procedures Douanieres ». */ + title: string; + /** Section a l'interieur du sujet, ex. « Regimes Douaniers ». */ + section: string; + /** Lien vers la page wiki, ex. `/dashboard/wiki/douanes`. */ + href: string; + text: string; + score: number; +} + +export interface TradeAskInput { + question: string; + language: string; + /** Tours precedents, du plus ancien au plus recent. */ + history: TradeTurn[]; + /** Extraits du wiki a citer en priorite. */ + passages: TradePassage[]; + /** Capacites ouvertes a cet utilisateur. Vide : l'assistant ne fait que repondre. */ + tools?: TradeToolDefinition[]; + invokeTool?: TradeToolInvoker; +} + +export interface TradeAiPort { + isAvailable(): boolean; + answer(input: TradeAskInput): Promise; +} + +/* -------------------------------------------------------------------------- */ +/* Recherche documentaire */ +/* -------------------------------------------------------------------------- */ + +export const TRADE_RETRIEVAL = 'TRADE_RETRIEVAL'; + +export interface TradeRetrievalPort { + /** Extraits du wiki les plus proches de la question, dans sa langue. */ + search(question: string, language: string, limit?: number): Promise; +} + +export const TRADE_EMBEDDINGS = 'TRADE_EMBEDDINGS'; + +export interface TradeEmbeddingPort { + isAvailable(): boolean; + /** Vecteurs normes, dans l'ordre des textes fournis. */ + embed(texts: string[]): Promise; +} + +/* -------------------------------------------------------------------------- */ +/* Quota */ +/* -------------------------------------------------------------------------- */ + +export const TRADE_QUOTA = 'TRADE_QUOTA'; + +export interface TradeUsage { + day: string; + resetsAt: string; + used: number; +} + +export interface TradeQuotaPort { + get(userId: string): Promise; + reserve(userId: string, day: string, limit: number): Promise; + release(userId: string, day: string): Promise; + recordTokens(userId: string, day: string, answer: TradeAnswer): Promise; +} + +/* -------------------------------------------------------------------------- */ +/* Conversations */ +/* -------------------------------------------------------------------------- */ + +export const TRADE_CONVERSATIONS = 'TRADE_CONVERSATIONS'; + +/** Source citee sous une reponse, telle qu'elle est persistee. */ +export interface TradeSource { + title: string; + section: string; + href: string; +} + +export interface TradeMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + sources: TradeSource[]; + /** Capacites invoquees pour produire ce message. Vide cote utilisateur. */ + actions: TradeAction[]; + createdAt: string; +} + +export interface TradeConversationSummary { + id: string; + title: string; + createdAt: string; + updatedAt: string; + messageCount: number; +} + +export interface TradeConversationRepository { + list(userId: string): Promise; + create(userId: string, title: string): Promise; + /** `null` si la conversation n'existe pas ou n'appartient pas a l'utilisateur. */ + find(userId: string, conversationId: string): Promise; + messages(userId: string, conversationId: string): Promise; + addMessage( + conversationId: string, + role: 'user' | 'assistant', + content: string, + sources?: TradeSource[], + actions?: TradeAction[] + ): Promise; + rename(userId: string, conversationId: string, title: string): Promise; + remove(userId: string, conversationId: string): Promise; +} diff --git a/apps/backend/src/domain/services/trade-assistant-policy.ts b/apps/backend/src/domain/services/trade-assistant-policy.ts new file mode 100644 index 0000000..dad39d2 --- /dev/null +++ b/apps/backend/src/domain/services/trade-assistant-policy.ts @@ -0,0 +1,33 @@ +import { SubscriptionPlanType } from '../value-objects/subscription-plan.vo'; + +/** + * Questions par utilisateur et par jour. + * + * `-1` signifie illimite, comme partout ailleurs dans le domaine + * (`maxLicenses`, `maxShipmentsPerYear`). Platinium est une offre sur devis : + * elle n'est pas plafonnee. + */ +export const TRADE_DAILY_LIMITS: Readonly> = { + BRONZE: 3, + SILVER: 10, + GOLD: 15, + PLATINIUM: -1, +}; + +export const TRADE_SUPPORT_EMAIL = 'support@xpeditis.com'; + +/** + * Limite d'une offre, avec repli sur Bronze. + * + * L'offre arrive d'une colonne de base de donnees : une valeur inconnue — + * ancienne offre, ligne ecrite a la main — donnait `undefined`, puis un + * `NaN` de bout en bout jusqu'a « NaN/undefined » dans l'interface. Le repli + * sur l'offre la plus restrictive est le seul comportement sur. + */ +export function tradeDailyLimit(plan: string): number { + return Object.prototype.hasOwnProperty.call(TRADE_DAILY_LIMITS, plan) + ? TRADE_DAILY_LIMITS[plan as SubscriptionPlanType] + : TRADE_DAILY_LIMITS.BRONZE; +} + +export const isUnlimitedTradeQuota = (limit: number): boolean => limit < 0;