@open-mercato/ai-assistant 0.6.7-develop.6862.1.c11a64ce0a → 0.6.7

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.
Files changed (114) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +29 -12
  3. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +0 -30
  4. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
  5. package/dist/modules/ai_assistant/api/ai/chat/route.js +0 -7
  6. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  7. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
  8. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/settings/route.js +13 -34
  10. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +0 -106
  12. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  13. package/dist/modules/ai_assistant/data/entities.js +0 -42
  14. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  15. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +1 -3
  16. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  17. package/dist/modules/ai_assistant/di.js +2 -6
  18. package/dist/modules/ai_assistant/di.js.map +2 -2
  19. package/dist/modules/ai_assistant/events.js +0 -6
  20. package/dist/modules/ai_assistant/events.js.map +2 -2
  21. package/dist/modules/ai_assistant/i18n/de.json +0 -42
  22. package/dist/modules/ai_assistant/i18n/en.json +0 -42
  23. package/dist/modules/ai_assistant/i18n/es.json +0 -42
  24. package/dist/modules/ai_assistant/i18n/pl.json +0 -42
  25. package/dist/modules/ai_assistant/lib/agent-runtime.js +5 -158
  26. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  28. package/dist/modules/ai_assistant/lib/auth.js +7 -6
  29. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  30. package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
  31. package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
  32. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +0 -3
  33. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  34. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +1 -8
  35. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  36. package/dist/modules/ai_assistant/lib/model-factory.js +1 -6
  37. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  38. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +0 -2
  39. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  40. package/dist/modules/ai_assistant/lib/tool-search.js +12 -4
  41. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  42. package/generated/entities/ai_agent_runtime_override/index.ts +0 -1
  43. package/generated/entities.ids.generated.ts +1 -2
  44. package/generated/entity-fields-registry.ts +1 -13
  45. package/package.json +12 -13
  46. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +0 -48
  47. package/src/modules/ai_assistant/api/ai/chat/route.ts +0 -10
  48. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +8 -58
  49. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +12 -10
  50. package/src/modules/ai_assistant/api/settings/route.ts +10 -33
  51. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +0 -146
  52. package/src/modules/ai_assistant/data/entities.ts +0 -56
  53. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +0 -4
  54. package/src/modules/ai_assistant/di.ts +1 -5
  55. package/src/modules/ai_assistant/events.ts +0 -21
  56. package/src/modules/ai_assistant/i18n/de.json +0 -42
  57. package/src/modules/ai_assistant/i18n/en.json +0 -42
  58. package/src/modules/ai_assistant/i18n/es.json +0 -42
  59. package/src/modules/ai_assistant/i18n/pl.json +0 -42
  60. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +5 -2
  61. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +0 -13
  62. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +0 -41
  63. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +0 -60
  64. package/src/modules/ai_assistant/lib/agent-runtime.ts +0 -259
  65. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +0 -10
  66. package/src/modules/ai_assistant/lib/auth.ts +15 -6
  67. package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
  68. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +0 -10
  69. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -32
  70. package/src/modules/ai_assistant/lib/model-factory.ts +0 -33
  71. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +0 -2
  72. package/src/modules/ai_assistant/lib/tool-search.ts +17 -4
  73. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +16 -388
  74. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +0 -80
  75. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +0 -7
  76. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +0 -124
  77. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +0 -7
  78. package/dist/modules/ai_assistant/api/moderation-flags/route.js +0 -121
  79. package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +0 -7
  80. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +0 -139
  81. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +0 -7
  82. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +0 -10
  83. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +0 -7
  84. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +0 -26
  85. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +0 -7
  86. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +0 -44
  87. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +0 -7
  88. package/dist/modules/ai_assistant/i18n/ko.json +0 -527
  89. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +0 -43
  90. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +0 -7
  91. package/dist/modules/ai_assistant/lib/moderation-policy.js +0 -24
  92. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +0 -7
  93. package/dist/modules/ai_assistant/lib/moderation.js +0 -109
  94. package/dist/modules/ai_assistant/lib/moderation.js.map +0 -7
  95. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +0 -19
  96. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +0 -7
  97. package/generated/entities/ai_moderation_flag/index.ts +0 -9
  98. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +0 -126
  99. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +0 -161
  100. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +0 -122
  101. package/src/modules/ai_assistant/api/moderation-flags/route.ts +0 -137
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +0 -186
  103. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +0 -24
  104. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +0 -12
  105. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +0 -79
  106. package/src/modules/ai_assistant/i18n/ko.json +0 -527
  107. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +0 -121
  108. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +0 -76
  109. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +0 -86
  110. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +0 -114
  111. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +0 -75
  112. package/src/modules/ai_assistant/lib/moderation-policy.ts +0 -77
  113. package/src/modules/ai_assistant/lib/moderation.ts +0 -179
  114. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +0 -21
@@ -1,121 +0,0 @@
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
- })
@@ -1,76 +0,0 @@
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
- })
@@ -1,86 +0,0 @@
1
- import {
2
- isModerationActive,
3
- resolveModerationPolicy,
4
- shouldFailClosed,
5
- } from '../moderation-policy'
6
-
7
- describe('resolveModerationPolicy — 5-step precedence', () => {
8
- it('1. untrustedInput wins over everything → enforced', () => {
9
- expect(
10
- resolveModerationPolicy({
11
- untrustedInput: true,
12
- perAgentOverride: false,
13
- tenantWideOverride: false,
14
- env: { OM_AI_INPUT_MODERATION: 'false' },
15
- }),
16
- ).toBe('enforced')
17
- })
18
-
19
- it('2. per-agent override wins over tenant-wide + env', () => {
20
- expect(
21
- resolveModerationPolicy({
22
- perAgentOverride: true,
23
- tenantWideOverride: false,
24
- env: { OM_AI_INPUT_MODERATION: 'false' },
25
- }),
26
- ).toBe('on')
27
- expect(
28
- resolveModerationPolicy({
29
- perAgentOverride: false,
30
- tenantWideOverride: true,
31
- env: { OM_AI_INPUT_MODERATION: 'true' },
32
- }),
33
- ).toBe('off')
34
- })
35
-
36
- it('3. tenant-wide override wins over env when per-agent is inherit', () => {
37
- expect(
38
- resolveModerationPolicy({
39
- perAgentOverride: null,
40
- tenantWideOverride: true,
41
- env: { OM_AI_INPUT_MODERATION: 'false' },
42
- }),
43
- ).toBe('on')
44
- expect(
45
- resolveModerationPolicy({
46
- perAgentOverride: undefined,
47
- tenantWideOverride: false,
48
- env: { OM_AI_INPUT_MODERATION: 'true' },
49
- }),
50
- ).toBe('off')
51
- })
52
-
53
- it('4. env default applies when both overrides inherit', () => {
54
- expect(
55
- resolveModerationPolicy({ perAgentOverride: null, tenantWideOverride: null, env: { OM_AI_INPUT_MODERATION: 'true' } }),
56
- ).toBe('on')
57
- expect(
58
- resolveModerationPolicy({ env: { OM_AI_INPUT_MODERATION: '1' } }),
59
- ).toBe('on')
60
- })
61
-
62
- it('5. defaults to off when nothing is set', () => {
63
- expect(resolveModerationPolicy({ env: {} })).toBe('off')
64
- expect(resolveModerationPolicy({ perAgentOverride: null, tenantWideOverride: null, env: {} })).toBe('off')
65
- })
66
-
67
- it('treats untrustedInput=false the same as unset (falls through)', () => {
68
- expect(
69
- resolveModerationPolicy({ untrustedInput: false, tenantWideOverride: true, env: {} }),
70
- ).toBe('on')
71
- })
72
- })
73
-
74
- describe('moderation policy helpers', () => {
75
- it('isModerationActive is true for enforced and on, false for off', () => {
76
- expect(isModerationActive('enforced')).toBe(true)
77
- expect(isModerationActive('on')).toBe(true)
78
- expect(isModerationActive('off')).toBe(false)
79
- })
80
-
81
- it('shouldFailClosed is true only for enforced (opt-in on fails open)', () => {
82
- expect(shouldFailClosed('enforced')).toBe(true)
83
- expect(shouldFailClosed('on')).toBe(false)
84
- expect(shouldFailClosed('off')).toBe(false)
85
- })
86
- })
@@ -1,114 +0,0 @@
1
- import {
2
- AiModerationUnavailableError,
3
- createModerationService,
4
- DEFAULT_MODERATION_MODEL,
5
- } from '../moderation'
6
-
7
- function jsonResponse(body: unknown, status = 200): Response {
8
- return {
9
- ok: status >= 200 && status < 300,
10
- status,
11
- json: async () => body,
12
- } as unknown as Response
13
- }
14
-
15
- function moderationBody(flagged: boolean): unknown {
16
- return {
17
- results: [
18
- {
19
- flagged,
20
- categories: { hate: flagged, violence: false },
21
- category_scores: { hate: flagged ? 0.97 : 0.01, violence: 0.002 },
22
- },
23
- ],
24
- }
25
- }
26
-
27
- describe('createModerationService.checkInput', () => {
28
- const service = createModerationService()
29
- let fetchSpy: jest.SpyInstance
30
-
31
- beforeEach(() => {
32
- fetchSpy = jest.spyOn(globalThis, 'fetch')
33
- })
34
- afterEach(() => {
35
- fetchSpy.mockRestore()
36
- })
37
-
38
- it('returns flagged=false with mapped categories for clean input', async () => {
39
- fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
40
- const result = await service.checkInput({ text: 'hello', apiKey: 'sk-test' })
41
- expect(result.flagged).toBe(false)
42
- expect(result.categories.hate).toEqual({ flagged: false, score: 0.01 })
43
- expect(result.categories.violence).toEqual({ flagged: false, score: 0.002 })
44
- expect(fetchSpy).toHaveBeenCalledTimes(1)
45
- })
46
-
47
- it('returns flagged=true with category flags + scores for flagged input', async () => {
48
- fetchSpy.mockResolvedValue(jsonResponse(moderationBody(true)))
49
- const result = await service.checkInput({ text: 'bad', apiKey: 'sk-test' })
50
- expect(result.flagged).toBe(true)
51
- expect(result.categories.hate).toEqual({ flagged: true, score: 0.97 })
52
- })
53
-
54
- it('sends the configured model + base URL and bearer auth', async () => {
55
- fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
56
- await service.checkInput({
57
- text: 'hi',
58
- apiKey: 'sk-secret',
59
- baseURL: 'https://proxy.example.com/v1/',
60
- model: 'omni-moderation-2026',
61
- })
62
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
63
- expect(url).toBe('https://proxy.example.com/v1/moderations')
64
- expect((init.headers as Record<string, string>).Authorization).toBe('Bearer sk-secret')
65
- expect(JSON.parse(init.body as string)).toEqual({ model: 'omni-moderation-2026', input: 'hi' })
66
- })
67
-
68
- it('defaults to omni-moderation-latest when no model is supplied', async () => {
69
- fetchSpy.mockResolvedValue(jsonResponse(moderationBody(false)))
70
- await service.checkInput({ text: 'hi', apiKey: 'sk-test' })
71
- const init = fetchSpy.mock.calls[0][1] as RequestInit
72
- expect(JSON.parse(init.body as string).model).toBe(DEFAULT_MODERATION_MODEL)
73
- })
74
-
75
- it('retries once on a 5xx and succeeds', async () => {
76
- fetchSpy
77
- .mockResolvedValueOnce(jsonResponse({ error: 'boom' }, 503))
78
- .mockResolvedValueOnce(jsonResponse(moderationBody(false)))
79
- const result = await service.checkInput({ text: 'hi', apiKey: 'sk-test' })
80
- expect(result.flagged).toBe(false)
81
- expect(fetchSpy).toHaveBeenCalledTimes(2)
82
- })
83
-
84
- it('throws AiModerationUnavailableError after a 5xx on both attempts', async () => {
85
- fetchSpy.mockResolvedValue(jsonResponse({ error: 'boom' }, 500))
86
- await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
87
- AiModerationUnavailableError,
88
- )
89
- expect(fetchSpy).toHaveBeenCalledTimes(2)
90
- })
91
-
92
- it('throws AiModerationUnavailableError on repeated network/timeout errors', async () => {
93
- fetchSpy.mockRejectedValue(new Error('AbortError: timed out'))
94
- await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
95
- AiModerationUnavailableError,
96
- )
97
- expect(fetchSpy).toHaveBeenCalledTimes(2)
98
- })
99
-
100
- it('does not retry a 4xx (credential/config error) and fails closed', async () => {
101
- fetchSpy.mockResolvedValue(jsonResponse({ error: 'unauthorized' }, 401))
102
- await expect(service.checkInput({ text: 'hi', apiKey: 'bad' })).rejects.toBeInstanceOf(
103
- AiModerationUnavailableError,
104
- )
105
- expect(fetchSpy).toHaveBeenCalledTimes(1)
106
- })
107
-
108
- it('throws AiModerationUnavailableError when the response schema is unexpected', async () => {
109
- fetchSpy.mockResolvedValue(jsonResponse({ unexpected: true }))
110
- await expect(service.checkInput({ text: 'hi', apiKey: 'sk-test' })).rejects.toBeInstanceOf(
111
- AiModerationUnavailableError,
112
- )
113
- })
114
- })
@@ -1,75 +0,0 @@
1
- import type { AwilixContainer } from 'awilix'
2
- import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { createLogger } from '@open-mercato/shared/lib/logger'
4
- import { AiModerationFlagRepository } from '../data/repositories/AiModerationFlagRepository'
5
- import { emitAiAssistantEvent } from '../events'
6
- import type { ModerationCategoryResult } from './moderation'
7
-
8
- const logger = createLogger('ai_assistant')
9
-
10
- export interface RecordModerationFlagInput {
11
- tenantId: string | null
12
- organizationId: string | null
13
- agentId: string
14
- userId: string
15
- providerId: string
16
- modelId: string
17
- categories: Record<string, ModerationCategoryResult>
18
- }
19
-
20
- /**
21
- * Persists one `ai_moderation_flags` audit row and emits
22
- * `ai_assistant.moderation_flag.created`, best-effort.
23
- *
24
- * SAFETY CONTRACT: this MUST NEVER throw — the moderation rejection is the
25
- * primary effect and is thrown by the gate regardless. A missing tenant scope
26
- * (e.g. a system-scope caller) means the row cannot be tenant-scoped, so the
27
- * audit write is skipped. Any persistence/emit failure is logged and
28
- * swallowed so the user still receives the rejection.
29
- *
30
- * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
31
- */
32
- export async function recordModerationFlag(
33
- input: RecordModerationFlagInput,
34
- container: AwilixContainer | undefined,
35
- ): Promise<void> {
36
- if (!container) return
37
- if (!input.tenantId) return
38
-
39
- let createdId: string | null = null
40
- try {
41
- const em = container.resolve<EntityManager>('em')
42
- const repo = new AiModerationFlagRepository(em.fork())
43
- const flag = await repo.create({
44
- tenantId: input.tenantId,
45
- organizationId: input.organizationId,
46
- agentId: input.agentId,
47
- userId: input.userId,
48
- providerId: input.providerId,
49
- modelId: input.modelId,
50
- categories: input.categories,
51
- })
52
- createdId = flag.id
53
- } catch (error) {
54
- logger.error('Failed to persist moderation flag (rejection still applies)', { err: error })
55
- return
56
- }
57
-
58
- // Emit AFTER a successful write, detached so a failed emit never retroactively
59
- // fails the audit insert.
60
- try {
61
- const flaggedCategories = Object.entries(input.categories)
62
- .filter(([, value]) => value.flagged)
63
- .map(([name]) => name)
64
- await emitAiAssistantEvent('ai_assistant.moderation_flag.created', {
65
- id: createdId,
66
- tenantId: input.tenantId,
67
- organizationId: input.organizationId,
68
- agentId: input.agentId,
69
- userId: input.userId,
70
- categories: flaggedCategories,
71
- })
72
- } catch (error) {
73
- logger.error('Failed to emit moderation_flag.created (audit row persisted)', { err: error })
74
- }
75
- }
@@ -1,77 +0,0 @@
1
- /**
2
- * Input-moderation policy resolution.
3
- *
4
- * Resolves the effective moderation policy for an agent turn using a fixed
5
- * precedence (first match wins):
6
- *
7
- * 1. `agentDef.untrustedInput === true` → `enforced` (tenant cannot disable).
8
- * 2. Per-agent tenant override (`ai_agent_runtime_overrides` row with an
9
- * `agent_id`) `input_moderation` → `on` / `off`.
10
- * 3. Tenant-wide override (row with `agent_id = NULL`) `input_moderation` →
11
- * `on` / `off`.
12
- * 4. Env default `OM_AI_INPUT_MODERATION` (parsed via `parseBooleanWithDefault`)
13
- * → `on` / `off`.
14
- * 5. Default `off`.
15
- *
16
- * Steps 4 and 5 collapse into `parseBooleanWithDefault(env, false)`.
17
- *
18
- * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md
19
- */
20
-
21
- import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
22
- import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
23
-
24
- /** Effective runtime moderation policy for a turn. */
25
- export type ModerationPolicy = 'enforced' | 'on' | 'off'
26
-
27
- /** The per-agent display state shown in settings (adds `inherit`). */
28
- export type ModerationPolicyDisplay = 'enforced' | 'on' | 'off' | 'inherit'
29
-
30
- export interface ResolveModerationPolicyInput {
31
- /** Whether the agent definition marks this surface as untrusted. */
32
- untrustedInput?: boolean
33
- /**
34
- * `input_moderation` from the agent-scoped tenant override row, or
35
- * `null`/`undefined` when the row is absent or leaves it unset (inherit).
36
- */
37
- perAgentOverride?: boolean | null
38
- /**
39
- * `input_moderation` from the tenant-wide override row (`agent_id = NULL`),
40
- * or `null`/`undefined` when absent/unset (inherit).
41
- */
42
- tenantWideOverride?: boolean | null
43
- /** Env lookup; reads `OM_AI_INPUT_MODERATION`. Defaults to `process.env`. */
44
- env?: EnvLookup
45
- }
46
-
47
- /**
48
- * Resolves the effective moderation policy following the 5-step precedence.
49
- * Pure and synchronous — the runtime hydrates the override values from the DB
50
- * before calling this.
51
- */
52
- export function resolveModerationPolicy(input: ResolveModerationPolicyInput): ModerationPolicy {
53
- if (input.untrustedInput === true) return 'enforced'
54
- if (input.perAgentOverride !== null && input.perAgentOverride !== undefined) {
55
- return input.perAgentOverride ? 'on' : 'off'
56
- }
57
- if (input.tenantWideOverride !== null && input.tenantWideOverride !== undefined) {
58
- return input.tenantWideOverride ? 'on' : 'off'
59
- }
60
- const env = input.env ?? process.env
61
- return parseBooleanWithDefault(env.OM_AI_INPUT_MODERATION, false) ? 'on' : 'off'
62
- }
63
-
64
- /** Whether a resolved policy should run the moderation gate for the turn. */
65
- export function isModerationActive(policy: ModerationPolicy): boolean {
66
- return policy === 'enforced' || policy === 'on'
67
- }
68
-
69
- /**
70
- * Whether an *unavailable* moderation check (endpoint outage) should fail
71
- * closed (reject the turn) rather than fail open (allow it through). Per spec:
72
- * only `enforced` (untrusted) surfaces fail closed; opt-in `on` surfaces prefer
73
- * availability and fail open. Both paths log. (`off` never reaches the gate.)
74
- */
75
- export function shouldFailClosed(policy: ModerationPolicy): boolean {
76
- return policy === 'enforced'
77
- }