@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +29 -12
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +0 -30
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +0 -7
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- 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 +13 -34
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +0 -106
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities.js +0 -42
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +1 -3
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/di.js +2 -6
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +0 -6
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +0 -42
- package/dist/modules/ai_assistant/i18n/en.json +0 -42
- package/dist/modules/ai_assistant/i18n/es.json +0 -42
- package/dist/modules/ai_assistant/i18n/pl.json +0 -42
- package/dist/modules/ai_assistant/lib/agent-runtime.js +5 -158
- 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 +7 -6
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
- package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +0 -3
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +1 -8
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +1 -6
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +0 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-search.js +12 -4
- package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
- package/generated/entities/ai_agent_runtime_override/index.ts +0 -1
- package/generated/entities.ids.generated.ts +1 -2
- package/generated/entity-fields-registry.ts +1 -13
- package/package.json +12 -13
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +0 -48
- package/src/modules/ai_assistant/api/ai/chat/route.ts +0 -10
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +8 -58
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +12 -10
- package/src/modules/ai_assistant/api/settings/route.ts +10 -33
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +0 -146
- package/src/modules/ai_assistant/data/entities.ts +0 -56
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +0 -4
- package/src/modules/ai_assistant/di.ts +1 -5
- package/src/modules/ai_assistant/events.ts +0 -21
- package/src/modules/ai_assistant/i18n/de.json +0 -42
- package/src/modules/ai_assistant/i18n/en.json +0 -42
- package/src/modules/ai_assistant/i18n/es.json +0 -42
- package/src/modules/ai_assistant/i18n/pl.json +0 -42
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +5 -2
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +0 -13
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +0 -41
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +0 -60
- package/src/modules/ai_assistant/lib/agent-runtime.ts +0 -259
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +0 -10
- package/src/modules/ai_assistant/lib/auth.ts +15 -6
- package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +0 -10
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -32
- package/src/modules/ai_assistant/lib/model-factory.ts +0 -33
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +0 -2
- package/src/modules/ai_assistant/lib/tool-search.ts +17 -4
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +16 -388
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +0 -80
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +0 -7
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +0 -124
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +0 -7
- package/dist/modules/ai_assistant/api/moderation-flags/route.js +0 -121
- package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +0 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +0 -139
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +0 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +0 -10
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +0 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +0 -26
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +0 -7
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +0 -44
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +0 -7
- package/dist/modules/ai_assistant/i18n/ko.json +0 -527
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +0 -43
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +0 -7
- package/dist/modules/ai_assistant/lib/moderation-policy.js +0 -24
- package/dist/modules/ai_assistant/lib/moderation-policy.js.map +0 -7
- package/dist/modules/ai_assistant/lib/moderation.js +0 -109
- package/dist/modules/ai_assistant/lib/moderation.js.map +0 -7
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +0 -19
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +0 -7
- package/generated/entities/ai_moderation_flag/index.ts +0 -9
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +0 -126
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +0 -161
- package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +0 -122
- package/src/modules/ai_assistant/api/moderation-flags/route.ts +0 -137
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +0 -186
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +0 -24
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +0 -12
- package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +0 -79
- package/src/modules/ai_assistant/i18n/ko.json +0 -527
- package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +0 -121
- package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +0 -76
- package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +0 -86
- package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +0 -114
- package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +0 -75
- package/src/modules/ai_assistant/lib/moderation-policy.ts +0 -77
- package/src/modules/ai_assistant/lib/moderation.ts +0 -179
- 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
|
-
}
|