@open-mercato/ai-assistant 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +12 -29
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
- package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +34 -13
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +106 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +42 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
- package/dist/modules/ai_assistant/di.js +6 -2
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +6 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +26 -0
- package/dist/modules/ai_assistant/i18n/en.json +26 -0
- package/dist/modules/ai_assistant/i18n/es.json +26 -0
- package/dist/modules/ai_assistant/i18n/pl.json +26 -0
- package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/auth.js +6 -7
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation.js +109 -0
- package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
- package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
- package/generated/entities/ai_moderation_flag/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +13 -1
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
- package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
- package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
- package/src/modules/ai_assistant/api/settings/route.ts +33 -10
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +146 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
- package/src/modules/ai_assistant/data/entities.ts +56 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
- package/src/modules/ai_assistant/di.ts +5 -1
- package/src/modules/ai_assistant/events.ts +21 -0
- package/src/modules/ai_assistant/i18n/de.json +26 -0
- package/src/modules/ai_assistant/i18n/en.json +26 -0
- package/src/modules/ai_assistant/i18n/es.json +26 -0
- package/src/modules/ai_assistant/i18n/pl.json +26 -0
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
- package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
- package/src/modules/ai_assistant/lib/auth.ts +6 -15
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
- package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
- package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
- package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
- package/src/modules/ai_assistant/lib/moderation.ts +179 -0
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
- package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
- package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
|
@@ -61,6 +61,12 @@ const events = [
|
|
|
61
61
|
category: 'lifecycle' as const,
|
|
62
62
|
clientBroadcast: true,
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: 'ai_assistant.moderation_flag.created',
|
|
66
|
+
label: 'AI Moderation Flag Created',
|
|
67
|
+
entity: 'ai_moderation_flag',
|
|
68
|
+
category: 'system' as const,
|
|
69
|
+
},
|
|
64
70
|
] as const
|
|
65
71
|
|
|
66
72
|
export const eventsConfig = createModuleEvents({
|
|
@@ -148,4 +154,19 @@ export interface AiConversationUnsharedPayload {
|
|
|
148
154
|
participantUserId: string
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Emitted (best-effort) when the input moderation gate blocks a turn and the
|
|
159
|
+
* audit row is persisted. Carries category flags only — never prompt content.
|
|
160
|
+
*
|
|
161
|
+
* Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
|
|
162
|
+
*/
|
|
163
|
+
export interface AiModerationFlagCreatedPayload {
|
|
164
|
+
id: string
|
|
165
|
+
tenantId: string
|
|
166
|
+
organizationId: string | null
|
|
167
|
+
agentId: string
|
|
168
|
+
userId: string
|
|
169
|
+
categories: string[]
|
|
170
|
+
}
|
|
171
|
+
|
|
151
172
|
export default eventsConfig
|
|
@@ -62,6 +62,18 @@
|
|
|
62
62
|
"ai_assistant.agents.model_override.subtitle": "Überschreibe Standardanbieter und -modell für diesen Mandanten und Agenten.",
|
|
63
63
|
"ai_assistant.agents.model_override.tenantOverride": "Mandanten-Überschreibung",
|
|
64
64
|
"ai_assistant.agents.model_override.title": "Anbieter und Modell",
|
|
65
|
+
"ai_assistant.agents.moderation.controlLabel": "Moderation",
|
|
66
|
+
"ai_assistant.agents.moderation.effectiveEnforced": "Erzwungen",
|
|
67
|
+
"ai_assistant.agents.moderation.effectiveOff": "Aus",
|
|
68
|
+
"ai_assistant.agents.moderation.effectiveOn": "Ein",
|
|
69
|
+
"ai_assistant.agents.moderation.enforcedBadge": "Erzwungen",
|
|
70
|
+
"ai_assistant.agents.moderation.enforcedHint": "Dieser Agent akzeptiert nicht vertrauenswürdige Eingaben, daher ist die Moderation immer aktiv und kann hier nicht deaktiviert werden.",
|
|
71
|
+
"ai_assistant.agents.moderation.inheritLabel": "Erben",
|
|
72
|
+
"ai_assistant.agents.moderation.offLabel": "Aus",
|
|
73
|
+
"ai_assistant.agents.moderation.onLabel": "Ein",
|
|
74
|
+
"ai_assistant.agents.moderation.saved": "Eingabe-Moderationseinstellung gespeichert.",
|
|
75
|
+
"ai_assistant.agents.moderation.subtitle": "Benutzereingaben vor dem Modelllauf auf unsichere Inhalte prüfen. Gilt nur, wenn der aufgelöste Anbieter Moderation unterstützt.",
|
|
76
|
+
"ai_assistant.agents.moderation.title": "Eingabe-Moderation",
|
|
65
77
|
"ai_assistant.agents.mutation_policy.clear": "Überschreibung löschen",
|
|
66
78
|
"ai_assistant.agents.mutation_policy.clearedMessage": "Überschreibung der Mutationsrichtlinie gelöscht; Agent verwendet die im Code deklarierte Richtlinie.",
|
|
67
79
|
"ai_assistant.agents.mutation_policy.codeDeclared": "Im Code deklariert",
|
|
@@ -297,6 +309,8 @@
|
|
|
297
309
|
"ai_assistant.dock.left": "Links andocken",
|
|
298
310
|
"ai_assistant.dock.minimize": "Minimieren",
|
|
299
311
|
"ai_assistant.dock.right": "Rechts andocken",
|
|
312
|
+
"ai_assistant.errors.moderationBlocked": "Ihre Nachricht wurde vom Inhaltssicherheitsfilter blockiert. Bitte formulieren Sie sie um und versuchen Sie es erneut.",
|
|
313
|
+
"ai_assistant.errors.moderationUnavailable": "Die Inhaltssicherheitsprüfung ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in einem Moment erneut.",
|
|
300
314
|
"ai_assistant.launcher.composerPlaceholder": "Fragen Sie mich etwas…",
|
|
301
315
|
"ai_assistant.launcher.dialogDescription": "Wähle einen Assistenten. Mit ↑/↓ navigieren, Enter startet, Esc schließt.",
|
|
302
316
|
"ai_assistant.launcher.dialogTitle": "AI assistants",
|
|
@@ -342,6 +356,18 @@
|
|
|
342
356
|
"ai_assistant.modelPicker.triggerAriaLabel": "KI-Modell auswählen",
|
|
343
357
|
"ai_assistant.modelPicker.useDefault": "Standard des Agenten verwenden",
|
|
344
358
|
"ai_assistant.modelPicker.useDefaultWithModel": "Standard des Agenten verwenden: {{model}}",
|
|
359
|
+
"ai_assistant.moderationFlags.columns.agent": "Agent",
|
|
360
|
+
"ai_assistant.moderationFlags.columns.categories": "Kategorien",
|
|
361
|
+
"ai_assistant.moderationFlags.columns.createdAt": "Markiert am",
|
|
362
|
+
"ai_assistant.moderationFlags.columns.user": "Benutzer",
|
|
363
|
+
"ai_assistant.moderationFlags.empty": "Keine markierten Nachrichten.",
|
|
364
|
+
"ai_assistant.moderationFlags.filters.agentPlaceholder": "modul.agent",
|
|
365
|
+
"ai_assistant.moderationFlags.filters.apply": "Anwenden",
|
|
366
|
+
"ai_assistant.moderationFlags.filters.from": "Von",
|
|
367
|
+
"ai_assistant.moderationFlags.filters.to": "Bis",
|
|
368
|
+
"ai_assistant.moderationFlags.navTitle": "Moderations-Flags",
|
|
369
|
+
"ai_assistant.moderationFlags.subtitle": "Vom Inhaltssicherheitsfilter blockierte Eingaben. Nur Kategorie-Flags und Scores — es wird kein Prompt-Inhalt gespeichert.",
|
|
370
|
+
"ai_assistant.moderationFlags.title": "Moderations-Flags",
|
|
345
371
|
"ai_assistant.notifications.conversation_shared.body": "Eine KI-Konversation wurde mit Ihnen geteilt.",
|
|
346
372
|
"ai_assistant.notifications.conversation_shared.title": "Konversation mit Ihnen geteilt",
|
|
347
373
|
"ai_assistant.notifications.conversation_shared.view_button": "Konversation öffnen",
|
|
@@ -62,6 +62,18 @@
|
|
|
62
62
|
"ai_assistant.agents.model_override.subtitle": "Override the default provider and model for this tenant and agent.",
|
|
63
63
|
"ai_assistant.agents.model_override.tenantOverride": "Tenant override",
|
|
64
64
|
"ai_assistant.agents.model_override.title": "Provider and model",
|
|
65
|
+
"ai_assistant.agents.moderation.controlLabel": "Moderation",
|
|
66
|
+
"ai_assistant.agents.moderation.effectiveEnforced": "Enforced",
|
|
67
|
+
"ai_assistant.agents.moderation.effectiveOff": "Off",
|
|
68
|
+
"ai_assistant.agents.moderation.effectiveOn": "On",
|
|
69
|
+
"ai_assistant.agents.moderation.enforcedBadge": "Enforced",
|
|
70
|
+
"ai_assistant.agents.moderation.enforcedHint": "This agent accepts untrusted input, so moderation is always on and cannot be disabled here.",
|
|
71
|
+
"ai_assistant.agents.moderation.inheritLabel": "Inherit",
|
|
72
|
+
"ai_assistant.agents.moderation.offLabel": "Off",
|
|
73
|
+
"ai_assistant.agents.moderation.onLabel": "On",
|
|
74
|
+
"ai_assistant.agents.moderation.saved": "Input moderation setting saved.",
|
|
75
|
+
"ai_assistant.agents.moderation.subtitle": "Screen user input for unsafe content before the model runs. Applies only when the resolved provider supports moderation.",
|
|
76
|
+
"ai_assistant.agents.moderation.title": "Input moderation",
|
|
65
77
|
"ai_assistant.agents.mutation_policy.clear": "Clear override",
|
|
66
78
|
"ai_assistant.agents.mutation_policy.clearedMessage": "Mutation policy override cleared; agent is using its code-declared policy.",
|
|
67
79
|
"ai_assistant.agents.mutation_policy.codeDeclared": "Code-declared",
|
|
@@ -297,6 +309,8 @@
|
|
|
297
309
|
"ai_assistant.dock.left": "Dock Left",
|
|
298
310
|
"ai_assistant.dock.minimize": "Minimize",
|
|
299
311
|
"ai_assistant.dock.right": "Dock Right",
|
|
312
|
+
"ai_assistant.errors.moderationBlocked": "Your message was blocked by the content safety filter. Please rephrase and try again.",
|
|
313
|
+
"ai_assistant.errors.moderationUnavailable": "The content safety check is temporarily unavailable. Please try again in a moment.",
|
|
300
314
|
"ai_assistant.launcher.composerPlaceholder": "Ask anything…",
|
|
301
315
|
"ai_assistant.launcher.dialogDescription": "Pick an assistant. Use ↑/↓ to navigate, Enter to launch, Esc to close.",
|
|
302
316
|
"ai_assistant.launcher.dialogTitle": "AI assistants",
|
|
@@ -342,6 +356,18 @@
|
|
|
342
356
|
"ai_assistant.modelPicker.triggerAriaLabel": "Select AI model",
|
|
343
357
|
"ai_assistant.modelPicker.useDefault": "Use agent default",
|
|
344
358
|
"ai_assistant.modelPicker.useDefaultWithModel": "Use agent default: {{model}}",
|
|
359
|
+
"ai_assistant.moderationFlags.columns.agent": "Agent",
|
|
360
|
+
"ai_assistant.moderationFlags.columns.categories": "Categories",
|
|
361
|
+
"ai_assistant.moderationFlags.columns.createdAt": "Flagged at",
|
|
362
|
+
"ai_assistant.moderationFlags.columns.user": "User",
|
|
363
|
+
"ai_assistant.moderationFlags.empty": "No flagged messages.",
|
|
364
|
+
"ai_assistant.moderationFlags.filters.agentPlaceholder": "module.agent",
|
|
365
|
+
"ai_assistant.moderationFlags.filters.apply": "Apply",
|
|
366
|
+
"ai_assistant.moderationFlags.filters.from": "From",
|
|
367
|
+
"ai_assistant.moderationFlags.filters.to": "To",
|
|
368
|
+
"ai_assistant.moderationFlags.navTitle": "Moderation flags",
|
|
369
|
+
"ai_assistant.moderationFlags.subtitle": "Inputs blocked by the content safety filter. Category flags and scores only — no prompt content is stored.",
|
|
370
|
+
"ai_assistant.moderationFlags.title": "Moderation flags",
|
|
345
371
|
"ai_assistant.notifications.conversation_shared.body": "An AI conversation has been shared with you.",
|
|
346
372
|
"ai_assistant.notifications.conversation_shared.title": "Conversation shared with you",
|
|
347
373
|
"ai_assistant.notifications.conversation_shared.view_button": "View Conversation",
|
|
@@ -62,6 +62,18 @@
|
|
|
62
62
|
"ai_assistant.agents.model_override.subtitle": "Anula el proveedor y el modelo predeterminados para este inquilino y agente.",
|
|
63
63
|
"ai_assistant.agents.model_override.tenantOverride": "Sobrescritura del tenant",
|
|
64
64
|
"ai_assistant.agents.model_override.title": "Proveedor y modelo",
|
|
65
|
+
"ai_assistant.agents.moderation.controlLabel": "Moderación",
|
|
66
|
+
"ai_assistant.agents.moderation.effectiveEnforced": "Forzada",
|
|
67
|
+
"ai_assistant.agents.moderation.effectiveOff": "Desactivada",
|
|
68
|
+
"ai_assistant.agents.moderation.effectiveOn": "Activada",
|
|
69
|
+
"ai_assistant.agents.moderation.enforcedBadge": "Forzada",
|
|
70
|
+
"ai_assistant.agents.moderation.enforcedHint": "Este agente acepta entradas no confiables, por lo que la moderación siempre está activada y no se puede desactivar aquí.",
|
|
71
|
+
"ai_assistant.agents.moderation.inheritLabel": "Heredar",
|
|
72
|
+
"ai_assistant.agents.moderation.offLabel": "Desactivada",
|
|
73
|
+
"ai_assistant.agents.moderation.onLabel": "Activada",
|
|
74
|
+
"ai_assistant.agents.moderation.saved": "Ajuste de moderación de entrada guardado.",
|
|
75
|
+
"ai_assistant.agents.moderation.subtitle": "Filtra la entrada del usuario en busca de contenido no seguro antes de ejecutar el modelo. Solo aplica cuando el proveedor resuelto admite moderación.",
|
|
76
|
+
"ai_assistant.agents.moderation.title": "Moderación de entrada",
|
|
65
77
|
"ai_assistant.agents.mutation_policy.clear": "Eliminar sobrescritura",
|
|
66
78
|
"ai_assistant.agents.mutation_policy.clearedMessage": "Sobrescritura de política de mutación eliminada; el agente usa la política declarada en el código.",
|
|
67
79
|
"ai_assistant.agents.mutation_policy.codeDeclared": "Declarada en el código",
|
|
@@ -297,6 +309,8 @@
|
|
|
297
309
|
"ai_assistant.dock.left": "Anclar a la izquierda",
|
|
298
310
|
"ai_assistant.dock.minimize": "Minimizar",
|
|
299
311
|
"ai_assistant.dock.right": "Anclar a la derecha",
|
|
312
|
+
"ai_assistant.errors.moderationBlocked": "Tu mensaje fue bloqueado por el filtro de seguridad de contenido. Reformúlalo e inténtalo de nuevo.",
|
|
313
|
+
"ai_assistant.errors.moderationUnavailable": "La verificación de seguridad de contenido no está disponible temporalmente. Inténtalo de nuevo en un momento.",
|
|
300
314
|
"ai_assistant.launcher.composerPlaceholder": "Pregunte lo que necesite…",
|
|
301
315
|
"ai_assistant.launcher.dialogDescription": "Elige un asistente. Usa ↑/↓ para navegar, Enter para iniciar, Esc para cerrar.",
|
|
302
316
|
"ai_assistant.launcher.dialogTitle": "AI assistants",
|
|
@@ -342,6 +356,18 @@
|
|
|
342
356
|
"ai_assistant.modelPicker.triggerAriaLabel": "Selector de modelo de IA",
|
|
343
357
|
"ai_assistant.modelPicker.useDefault": "Usar predeterminado",
|
|
344
358
|
"ai_assistant.modelPicker.useDefaultWithModel": "Usar predeterminado del agente: {{model}}",
|
|
359
|
+
"ai_assistant.moderationFlags.columns.agent": "Agente",
|
|
360
|
+
"ai_assistant.moderationFlags.columns.categories": "Categorías",
|
|
361
|
+
"ai_assistant.moderationFlags.columns.createdAt": "Marcado el",
|
|
362
|
+
"ai_assistant.moderationFlags.columns.user": "Usuario",
|
|
363
|
+
"ai_assistant.moderationFlags.empty": "No hay mensajes marcados.",
|
|
364
|
+
"ai_assistant.moderationFlags.filters.agentPlaceholder": "modulo.agente",
|
|
365
|
+
"ai_assistant.moderationFlags.filters.apply": "Aplicar",
|
|
366
|
+
"ai_assistant.moderationFlags.filters.from": "Desde",
|
|
367
|
+
"ai_assistant.moderationFlags.filters.to": "Hasta",
|
|
368
|
+
"ai_assistant.moderationFlags.navTitle": "Marcas de moderación",
|
|
369
|
+
"ai_assistant.moderationFlags.subtitle": "Entradas bloqueadas por el filtro de seguridad de contenido. Solo indicadores de categoría y puntuaciones — no se almacena el contenido del prompt.",
|
|
370
|
+
"ai_assistant.moderationFlags.title": "Marcas de moderación",
|
|
345
371
|
"ai_assistant.notifications.conversation_shared.body": "Se ha compartido una conversación de IA con usted.",
|
|
346
372
|
"ai_assistant.notifications.conversation_shared.title": "Conversación compartida con usted",
|
|
347
373
|
"ai_assistant.notifications.conversation_shared.view_button": "Ver conversación",
|
|
@@ -62,6 +62,18 @@
|
|
|
62
62
|
"ai_assistant.agents.model_override.subtitle": "Nadpisz domyślnego dostawcę i model dla tego najemcy i agenta.",
|
|
63
63
|
"ai_assistant.agents.model_override.tenantOverride": "Nadpisanie tenant",
|
|
64
64
|
"ai_assistant.agents.model_override.title": "Dostawca i model",
|
|
65
|
+
"ai_assistant.agents.moderation.controlLabel": "Moderacja",
|
|
66
|
+
"ai_assistant.agents.moderation.effectiveEnforced": "Wymuszona",
|
|
67
|
+
"ai_assistant.agents.moderation.effectiveOff": "Wyłączona",
|
|
68
|
+
"ai_assistant.agents.moderation.effectiveOn": "Włączona",
|
|
69
|
+
"ai_assistant.agents.moderation.enforcedBadge": "Wymuszona",
|
|
70
|
+
"ai_assistant.agents.moderation.enforcedHint": "Ten agent przyjmuje niezaufane dane wejściowe, więc moderacja jest zawsze włączona i nie można jej tutaj wyłączyć.",
|
|
71
|
+
"ai_assistant.agents.moderation.inheritLabel": "Dziedzicz",
|
|
72
|
+
"ai_assistant.agents.moderation.offLabel": "Wyłączona",
|
|
73
|
+
"ai_assistant.agents.moderation.onLabel": "Włączona",
|
|
74
|
+
"ai_assistant.agents.moderation.saved": "Zapisano ustawienie moderacji danych wejściowych.",
|
|
75
|
+
"ai_assistant.agents.moderation.subtitle": "Filtruj dane wejściowe użytkownika pod kątem niebezpiecznych treści przed uruchomieniem modelu. Działa tylko, gdy wybrany dostawca obsługuje moderację.",
|
|
76
|
+
"ai_assistant.agents.moderation.title": "Moderacja danych wejściowych",
|
|
65
77
|
"ai_assistant.agents.mutation_policy.clear": "Wyczyść nadpisanie",
|
|
66
78
|
"ai_assistant.agents.mutation_policy.clearedMessage": "Nadpisanie polityki mutacji usunięte; agent korzysta z polityki zadeklarowanej w kodzie.",
|
|
67
79
|
"ai_assistant.agents.mutation_policy.codeDeclared": "Zadeklarowane w kodzie",
|
|
@@ -297,6 +309,8 @@
|
|
|
297
309
|
"ai_assistant.dock.left": "Dokuj po lewej",
|
|
298
310
|
"ai_assistant.dock.minimize": "Minimalizuj",
|
|
299
311
|
"ai_assistant.dock.right": "Dokuj po prawej",
|
|
312
|
+
"ai_assistant.errors.moderationBlocked": "Twoja wiadomość została zablokowana przez filtr bezpieczeństwa treści. Przeformułuj ją i spróbuj ponownie.",
|
|
313
|
+
"ai_assistant.errors.moderationUnavailable": "Sprawdzanie bezpieczeństwa treści jest chwilowo niedostępne. Spróbuj ponownie za chwilę.",
|
|
300
314
|
"ai_assistant.launcher.composerPlaceholder": "Zapytaj o cokolwiek…",
|
|
301
315
|
"ai_assistant.launcher.dialogDescription": "Wybierz asystenta. Nawiguj klawiszami ↑/↓, Enter uruchamia, Esc zamyka.",
|
|
302
316
|
"ai_assistant.launcher.dialogTitle": "Asystenci AI",
|
|
@@ -342,6 +356,18 @@
|
|
|
342
356
|
"ai_assistant.modelPicker.triggerAriaLabel": "Wybierz model AI",
|
|
343
357
|
"ai_assistant.modelPicker.useDefault": "Użyj domyślnego agenta",
|
|
344
358
|
"ai_assistant.modelPicker.useDefaultWithModel": "Użyj domyślnego agenta: {{model}}",
|
|
359
|
+
"ai_assistant.moderationFlags.columns.agent": "Agent",
|
|
360
|
+
"ai_assistant.moderationFlags.columns.categories": "Kategorie",
|
|
361
|
+
"ai_assistant.moderationFlags.columns.createdAt": "Oznaczono",
|
|
362
|
+
"ai_assistant.moderationFlags.columns.user": "Użytkownik",
|
|
363
|
+
"ai_assistant.moderationFlags.empty": "Brak oznaczonych wiadomości.",
|
|
364
|
+
"ai_assistant.moderationFlags.filters.agentPlaceholder": "moduł.agent",
|
|
365
|
+
"ai_assistant.moderationFlags.filters.apply": "Zastosuj",
|
|
366
|
+
"ai_assistant.moderationFlags.filters.from": "Od",
|
|
367
|
+
"ai_assistant.moderationFlags.filters.to": "Do",
|
|
368
|
+
"ai_assistant.moderationFlags.navTitle": "Flagi moderacji",
|
|
369
|
+
"ai_assistant.moderationFlags.subtitle": "Dane wejściowe zablokowane przez filtr bezpieczeństwa treści. Tylko flagi kategorii i wyniki — treść promptu nie jest przechowywana.",
|
|
370
|
+
"ai_assistant.moderationFlags.title": "Flagi moderacji",
|
|
345
371
|
"ai_assistant.notifications.conversation_shared.body": "Udostępniono Ci konwersację z asystentem AI.",
|
|
346
372
|
"ai_assistant.notifications.conversation_shared.title": "Udostępniono Ci konwersację",
|
|
347
373
|
"ai_assistant.notifications.conversation_shared.view_button": "Otwórz konwersację",
|
|
@@ -137,7 +137,7 @@ describe('hasRequiredFeatures', () => {
|
|
|
137
137
|
).toBe(true)
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
it('
|
|
140
|
+
it('uses the shared policy even when a legacy rbacService argument is provided', () => {
|
|
141
141
|
const rbacService = {
|
|
142
142
|
hasAllFeatures: jest.fn().mockReturnValue(true),
|
|
143
143
|
}
|
|
@@ -148,9 +148,6 @@ describe('hasRequiredFeatures', () => {
|
|
|
148
148
|
rbacService as any
|
|
149
149
|
)
|
|
150
150
|
expect(result).toBe(true)
|
|
151
|
-
expect(rbacService.hasAllFeatures).
|
|
152
|
-
['customers.view'],
|
|
153
|
-
['customers.view']
|
|
154
|
-
)
|
|
151
|
+
expect(rbacService.hasAllFeatures).not.toHaveBeenCalled()
|
|
155
152
|
})
|
|
156
153
|
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { extractLatestUserText, runInputModerationGate } from '../agent-runtime'
|
|
2
|
+
import {
|
|
3
|
+
AiModerationBlockedError,
|
|
4
|
+
AiModerationUnavailableError,
|
|
5
|
+
type ModerationResult,
|
|
6
|
+
type ModerationService,
|
|
7
|
+
} from '../moderation'
|
|
8
|
+
|
|
9
|
+
function serviceReturning(result: ModerationResult): ModerationService {
|
|
10
|
+
return { checkInput: jest.fn(async () => result) }
|
|
11
|
+
}
|
|
12
|
+
function serviceThrowing(error: unknown): ModerationService {
|
|
13
|
+
return { checkInput: jest.fn(async () => { throw error }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CLEAN: ModerationResult = { flagged: false, categories: { hate: { flagged: false, score: 0.01 } } }
|
|
17
|
+
const FLAGGED: ModerationResult = { flagged: true, categories: { hate: { flagged: true, score: 0.98 } } }
|
|
18
|
+
|
|
19
|
+
const base = {
|
|
20
|
+
supportsInputModeration: true,
|
|
21
|
+
userText: 'hello there',
|
|
22
|
+
resolveApiKey: () => 'sk-test' as string | null,
|
|
23
|
+
env: {} as Record<string, string | undefined>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('extractLatestUserText', () => {
|
|
27
|
+
it('returns the text parts of the most recent user message', () => {
|
|
28
|
+
const messages = [
|
|
29
|
+
{ role: 'user', parts: [{ type: 'text', text: 'first' }] },
|
|
30
|
+
{ role: 'assistant', parts: [{ type: 'text', text: 'reply' }] },
|
|
31
|
+
{ role: 'user', parts: [{ type: 'text', text: 'second' }, { type: 'text', text: 'line' }] },
|
|
32
|
+
] as never
|
|
33
|
+
expect(extractLatestUserText(messages)).toBe('second\nline')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('falls back to string content and returns empty when no user message', () => {
|
|
37
|
+
expect(extractLatestUserText([{ role: 'user', content: 'plain' }] as never)).toBe('plain')
|
|
38
|
+
expect(extractLatestUserText([{ role: 'assistant', parts: [{ type: 'text', text: 'x' }] }] as never)).toBe('')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('runInputModerationGate', () => {
|
|
43
|
+
it('skips entirely when policy resolves to off (no untrusted, no override, no env)', async () => {
|
|
44
|
+
const service = serviceReturning(FLAGGED)
|
|
45
|
+
await expect(runInputModerationGate({ ...base, service })).resolves.toBeUndefined()
|
|
46
|
+
expect(service.checkInput).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('skips when the provider does not support moderation, even on an enforced surface', async () => {
|
|
50
|
+
const service = serviceReturning(FLAGGED)
|
|
51
|
+
await expect(
|
|
52
|
+
runInputModerationGate({ ...base, untrustedInput: true, supportsInputModeration: false, service }),
|
|
53
|
+
).resolves.toBeUndefined()
|
|
54
|
+
expect(service.checkInput).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('skips when the user text is empty', async () => {
|
|
58
|
+
const service = serviceReturning(FLAGGED)
|
|
59
|
+
await expect(
|
|
60
|
+
runInputModerationGate({ ...base, untrustedInput: true, userText: ' ', service }),
|
|
61
|
+
).resolves.toBeUndefined()
|
|
62
|
+
expect(service.checkInput).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('passes clean input through on an enabled surface', async () => {
|
|
66
|
+
const service = serviceReturning(CLEAN)
|
|
67
|
+
await expect(
|
|
68
|
+
runInputModerationGate({ ...base, perAgentOverride: true, service }),
|
|
69
|
+
).resolves.toBeUndefined()
|
|
70
|
+
expect(service.checkInput).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('throws AiModerationBlockedError (with categories) on flagged input', async () => {
|
|
74
|
+
const service = serviceReturning(FLAGGED)
|
|
75
|
+
const error = await runInputModerationGate({ ...base, untrustedInput: true, service }).catch((e) => e)
|
|
76
|
+
expect(error).toBeInstanceOf(AiModerationBlockedError)
|
|
77
|
+
expect((error as AiModerationBlockedError).categories.hate.flagged).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('runs the onFlagged side effect before throwing, and the throw still applies if it fails', async () => {
|
|
81
|
+
const service = serviceReturning(FLAGGED)
|
|
82
|
+
const onFlagged = jest.fn(async () => { throw new Error('db down') })
|
|
83
|
+
const error = await runInputModerationGate({ ...base, untrustedInput: true, service, onFlagged }).catch((e) => e)
|
|
84
|
+
expect(onFlagged).toHaveBeenCalledWith(FLAGGED.categories)
|
|
85
|
+
expect(error).toBeInstanceOf(AiModerationBlockedError)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('fails CLOSED on an enforced surface when the endpoint is unavailable', async () => {
|
|
89
|
+
const service = serviceThrowing(new AiModerationUnavailableError('endpoint down'))
|
|
90
|
+
await expect(
|
|
91
|
+
runInputModerationGate({ ...base, untrustedInput: true, service }),
|
|
92
|
+
).rejects.toBeInstanceOf(AiModerationUnavailableError)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('fails OPEN on an opt-in (on) surface when the endpoint is unavailable', async () => {
|
|
96
|
+
const service = serviceThrowing(new AiModerationUnavailableError('endpoint down'))
|
|
97
|
+
await expect(
|
|
98
|
+
runInputModerationGate({ ...base, perAgentOverride: true, service }),
|
|
99
|
+
).resolves.toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('fails CLOSED on enforced when the service is missing from the container', async () => {
|
|
103
|
+
await expect(
|
|
104
|
+
runInputModerationGate({ ...base, untrustedInput: true, service: null }),
|
|
105
|
+
).rejects.toBeInstanceOf(AiModerationUnavailableError)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('fails OPEN on opt-in when the API key is missing', async () => {
|
|
109
|
+
const service = serviceReturning(CLEAN)
|
|
110
|
+
await expect(
|
|
111
|
+
runInputModerationGate({ ...base, perAgentOverride: true, resolveApiKey: () => null, service }),
|
|
112
|
+
).resolves.toBeUndefined()
|
|
113
|
+
expect(service.checkInput).not.toHaveBeenCalled()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not resolve the API key on the common skip path (policy off)', async () => {
|
|
117
|
+
const resolveApiKey = jest.fn(() => 'sk-test' as string | null)
|
|
118
|
+
await expect(runInputModerationGate({ ...base, resolveApiKey, service: serviceReturning(CLEAN) })).resolves.toBeUndefined()
|
|
119
|
+
expect(resolveApiKey).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -87,4 +87,17 @@ describe('AnthropicAdapter', () => {
|
|
|
87
87
|
})
|
|
88
88
|
expect(model).toBeDefined()
|
|
89
89
|
})
|
|
90
|
+
|
|
91
|
+
it('maps an end-user identifier into the Anthropic metadata.userId providerOptions fragment', () => {
|
|
92
|
+
// Key MUST be the AI SDK provider-option name `userId` (camelCase); the SDK
|
|
93
|
+
// translates it to the `metadata.user_id` request-body field and strips
|
|
94
|
+
// unknown providerOptions keys, so a snake_case key would be dropped.
|
|
95
|
+
expect(adapter.mapEndUserIdentifier?.('hashed-id')).toEqual({
|
|
96
|
+
anthropic: { metadata: { userId: 'hashed-id' } },
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does not advertise input moderation support (Anthropic has no moderation endpoint)', () => {
|
|
101
|
+
expect(adapter.supportsInputModeration).toBeFalsy()
|
|
102
|
+
})
|
|
90
103
|
})
|
|
@@ -330,3 +330,44 @@ describe('LM Studio preset', () => {
|
|
|
330
330
|
expect(model).toBeDefined()
|
|
331
331
|
})
|
|
332
332
|
})
|
|
333
|
+
|
|
334
|
+
describe('OpenAIAdapter — safety identifier + moderation capability', () => {
|
|
335
|
+
it('maps an end-user identifier into the OpenAI safetyIdentifier providerOptions fragment', () => {
|
|
336
|
+
// Key MUST be the AI SDK provider-option name `safetyIdentifier` (camelCase);
|
|
337
|
+
// the SDK translates it to the `safety_identifier` request-body field and
|
|
338
|
+
// strips unknown providerOptions keys, so a snake_case key would be dropped.
|
|
339
|
+
const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((preset) => preset.id === 'openai')!
|
|
340
|
+
const provider = createOpenAICompatibleProvider(openaiPreset)
|
|
341
|
+
expect(provider.mapEndUserIdentifier?.('hashed-id')).toEqual({
|
|
342
|
+
openai: { safetyIdentifier: 'hashed-id' },
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('does not map a safety identifier for OpenAI-compatible backends (OpenAI-only param)', () => {
|
|
347
|
+
// `safety_identifier` is an OpenAI-specific body field; compatible/self-hosted
|
|
348
|
+
// backends (Groq, DeepInfra, Azure, Ollama, …) may reject the unknown param,
|
|
349
|
+
// so they must not advertise the mapping.
|
|
350
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
351
|
+
expect(provider.mapEndUserIdentifier).toBeUndefined()
|
|
352
|
+
for (const preset of OPENAI_COMPATIBLE_PRESETS) {
|
|
353
|
+
const built = createOpenAICompatibleProvider(preset)
|
|
354
|
+
if (preset.id === 'openai') {
|
|
355
|
+
expect(built.mapEndUserIdentifier).toBeDefined()
|
|
356
|
+
} else {
|
|
357
|
+
expect(built.mapEndUserIdentifier).toBeUndefined()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('marks the native OpenAI preset as supporting input moderation', () => {
|
|
363
|
+
const openaiPreset = OPENAI_COMPATIBLE_PRESETS.find((preset) => preset.id === 'openai')
|
|
364
|
+
expect(openaiPreset).toBeDefined()
|
|
365
|
+
const provider = createOpenAICompatibleProvider(openaiPreset!)
|
|
366
|
+
expect(provider.supportsInputModeration).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('leaves OpenAI-compatible backends without moderation support', () => {
|
|
370
|
+
const provider = createOpenAICompatibleProvider(DEEPINFRA_PRESET)
|
|
371
|
+
expect(provider.supportsInputModeration).toBe(false)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -1216,6 +1216,66 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeOverride (re
|
|
|
1216
1216
|
})
|
|
1217
1217
|
})
|
|
1218
1218
|
|
|
1219
|
+
describe('createModelFactory — end-user safety identifier + moderation capability', () => {
|
|
1220
|
+
// The shared makeProvider helper lists fields explicitly and does not carry
|
|
1221
|
+
// the optional mapEndUserIdentifier / supportsInputModeration members, so we
|
|
1222
|
+
// build providers inline here.
|
|
1223
|
+
type IdentifierProvider = FakeProvider & {
|
|
1224
|
+
mapEndUserIdentifier?: (identifier: string) => Record<string, unknown>
|
|
1225
|
+
supportsInputModeration?: boolean
|
|
1226
|
+
}
|
|
1227
|
+
function makeIdentifierProvider(overrides: Partial<IdentifierProvider> = {}): IdentifierProvider {
|
|
1228
|
+
return {
|
|
1229
|
+
id: 'test-provider',
|
|
1230
|
+
defaultModel: 'provider-default-model',
|
|
1231
|
+
resolveApiKey: () => 'test-api-key',
|
|
1232
|
+
createModel: (options) => ({ kind: 'fake-model', ...options }),
|
|
1233
|
+
isConfigured: () => true,
|
|
1234
|
+
...overrides,
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
it('emits the provider-mapped providerOptions fragment when an identifier is supplied', () => {
|
|
1239
|
+
const mapEndUserIdentifier = jest.fn((id: string) => ({ openai: { safetyIdentifier: id } }))
|
|
1240
|
+
const provider = makeIdentifierProvider({ mapEndUserIdentifier, supportsInputModeration: true })
|
|
1241
|
+
const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
|
|
1242
|
+
const resolution = factory.resolveModel({ endUserIdentifier: 'hashed-user' })
|
|
1243
|
+
expect(mapEndUserIdentifier).toHaveBeenCalledWith('hashed-user')
|
|
1244
|
+
expect(resolution.providerOptions).toEqual({ openai: { safetyIdentifier: 'hashed-user' } })
|
|
1245
|
+
expect(resolution.supportsInputModeration).toBe(true)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
it('forwards the identifier to the adapter createModel options', () => {
|
|
1249
|
+
const createModel = jest.fn((options: { modelId: string; apiKey: string }) => ({ ...options }))
|
|
1250
|
+
const provider = makeIdentifierProvider({
|
|
1251
|
+
createModel,
|
|
1252
|
+
mapEndUserIdentifier: (id: string) => ({ openai: { safetyIdentifier: id } }),
|
|
1253
|
+
})
|
|
1254
|
+
const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
|
|
1255
|
+
factory.resolveModel({ endUserIdentifier: 'hashed-user' })
|
|
1256
|
+
expect(createModel).toHaveBeenCalledWith(
|
|
1257
|
+
expect.objectContaining({ endUserIdentifier: 'hashed-user' }),
|
|
1258
|
+
)
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
it('omits providerOptions when no identifier is supplied', () => {
|
|
1262
|
+
const mapEndUserIdentifier = jest.fn((id: string) => ({ openai: { safety_identifier: id } }))
|
|
1263
|
+
const provider = makeIdentifierProvider({ mapEndUserIdentifier })
|
|
1264
|
+
const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
|
|
1265
|
+
const resolution = factory.resolveModel({})
|
|
1266
|
+
expect(mapEndUserIdentifier).not.toHaveBeenCalled()
|
|
1267
|
+
expect(resolution.providerOptions).toBeUndefined()
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
it('omits providerOptions when the provider has no mapping, and reports moderation unsupported', () => {
|
|
1271
|
+
const provider = makeIdentifierProvider()
|
|
1272
|
+
const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
|
|
1273
|
+
const resolution = factory.resolveModel({ endUserIdentifier: 'hashed-user' })
|
|
1274
|
+
expect(resolution.providerOptions).toBeUndefined()
|
|
1275
|
+
expect(resolution.supportsInputModeration).toBe(false)
|
|
1276
|
+
})
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1219
1279
|
describe('createModelFactory — vendor-prefix gateways', () => {
|
|
1220
1280
|
function gatewayProviders(
|
|
1221
1281
|
overrides: { openrouterConfigured?: boolean } = {},
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
|
|
3
|
+
const createMock = jest.fn()
|
|
4
|
+
const emitMock = jest.fn()
|
|
5
|
+
|
|
6
|
+
jest.mock('../../events', () => ({
|
|
7
|
+
emitAiAssistantEvent: (...args: unknown[]) => emitMock(...args),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('../../data/repositories/AiModerationFlagRepository', () => ({
|
|
10
|
+
AiModerationFlagRepository: jest.fn().mockImplementation(() => ({ create: createMock })),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import { recordModerationFlag } from '../moderation-flag-recorder'
|
|
14
|
+
|
|
15
|
+
function fakeContainer(): AwilixContainer {
|
|
16
|
+
return {
|
|
17
|
+
resolve: () => ({ fork: () => ({}) }),
|
|
18
|
+
} as unknown as AwilixContainer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const baseInput = {
|
|
22
|
+
tenantId: 'tenant-1',
|
|
23
|
+
organizationId: 'org-1',
|
|
24
|
+
agentId: 'customers.account_assistant',
|
|
25
|
+
userId: 'user-1',
|
|
26
|
+
providerId: 'openai',
|
|
27
|
+
modelId: 'gpt-5-mini',
|
|
28
|
+
categories: {
|
|
29
|
+
hate: { flagged: true, score: 0.97 },
|
|
30
|
+
violence: { flagged: false, score: 0.01 },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('recordModerationFlag', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
createMock.mockReset()
|
|
37
|
+
emitMock.mockReset()
|
|
38
|
+
createMock.mockResolvedValue({ id: 'flag-1' })
|
|
39
|
+
emitMock.mockResolvedValue(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('persists the audit row and emits the event with only flagged categories', async () => {
|
|
43
|
+
await recordModerationFlag(baseInput, fakeContainer())
|
|
44
|
+
expect(createMock).toHaveBeenCalledWith(
|
|
45
|
+
expect.objectContaining({ tenantId: 'tenant-1', agentId: 'customers.account_assistant', providerId: 'openai' }),
|
|
46
|
+
)
|
|
47
|
+
expect(emitMock).toHaveBeenCalledWith(
|
|
48
|
+
'ai_assistant.moderation_flag.created',
|
|
49
|
+
expect.objectContaining({ id: 'flag-1', tenantId: 'tenant-1', categories: ['hate'] }),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('skips entirely when tenantId is null (cannot tenant-scope the audit row)', async () => {
|
|
54
|
+
await recordModerationFlag({ ...baseInput, tenantId: null }, fakeContainer())
|
|
55
|
+
expect(createMock).not.toHaveBeenCalled()
|
|
56
|
+
expect(emitMock).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('skips when no container is available', async () => {
|
|
60
|
+
await recordModerationFlag(baseInput, undefined)
|
|
61
|
+
expect(createMock).not.toHaveBeenCalled()
|
|
62
|
+
expect(emitMock).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('swallows a persistence failure (never throws) and does not emit', async () => {
|
|
66
|
+
createMock.mockRejectedValue(new Error('db down'))
|
|
67
|
+
await expect(recordModerationFlag(baseInput, fakeContainer())).resolves.toBeUndefined()
|
|
68
|
+
expect(emitMock).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('swallows an emit failure after a successful persist (never throws)', async () => {
|
|
72
|
+
emitMock.mockRejectedValue(new Error('bus down'))
|
|
73
|
+
await expect(recordModerationFlag(baseInput, fakeContainer())).resolves.toBeUndefined()
|
|
74
|
+
expect(createMock).toHaveBeenCalledTimes(1)
|
|
75
|
+
})
|
|
76
|
+
})
|