@open-mercato/ai-assistant 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +121 -15
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.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/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- 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 +42 -0
- package/dist/modules/ai_assistant/i18n/en.json +42 -0
- package/dist/modules/ai_assistant/i18n/es.json +42 -0
- package/dist/modules/ai_assistant/i18n/ko.json +527 -0
- package/dist/modules/ai_assistant/i18n/pl.json +42 -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/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 +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 +13 -12
- 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 +161 -15
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
- 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/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
- 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 +42 -0
- package/src/modules/ai_assistant/i18n/en.json +42 -0
- package/src/modules/ai_assistant/i18n/es.json +42 -0
- package/src/modules/ai_assistant/i18n/ko.json +527 -0
- package/src/modules/ai_assistant/i18n/pl.json +42 -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/entity-graph.ts +3 -3
- 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
|
@@ -27,6 +27,20 @@ import {
|
|
|
27
27
|
import type { StopCondition } from 'ai'
|
|
28
28
|
import type { ZodTypeAny } from 'zod'
|
|
29
29
|
import { createModelFactory, resolveAllowRuntimeOverride } from './model-factory'
|
|
30
|
+
import { computeEndUserIdentifier } from '@open-mercato/shared/lib/ai/safety-identifier'
|
|
31
|
+
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
32
|
+
import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
33
|
+
import {
|
|
34
|
+
AiModerationBlockedError,
|
|
35
|
+
AiModerationUnavailableError,
|
|
36
|
+
type ModerationService,
|
|
37
|
+
} from './moderation'
|
|
38
|
+
import {
|
|
39
|
+
isModerationActive,
|
|
40
|
+
resolveModerationPolicy,
|
|
41
|
+
shouldFailClosed,
|
|
42
|
+
} from './moderation-policy'
|
|
43
|
+
import { recordModerationFlag } from './moderation-flag-recorder'
|
|
30
44
|
import type {
|
|
31
45
|
AiAgentDefinition,
|
|
32
46
|
AiAgentLoopConfig,
|
|
@@ -342,6 +356,16 @@ export interface PreparedAiSdkOptions {
|
|
|
342
356
|
* Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
343
357
|
*/
|
|
344
358
|
toolLoopAgent?: ToolLoopAgent<never, ToolSet>
|
|
359
|
+
/**
|
|
360
|
+
* Provider-specific `providerOptions` fragment carrying the mapped end-user
|
|
361
|
+
* safety identifier (e.g. OpenAI `safety_identifier`). Present only when an
|
|
362
|
+
* identifier was derived and the resolved provider supports the mapping.
|
|
363
|
+
* Callers using the `generateText` escape-hatch MUST forward it to the SDK
|
|
364
|
+
* call so the identifier reaches the provider.
|
|
365
|
+
*
|
|
366
|
+
* Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
|
|
367
|
+
*/
|
|
368
|
+
providerOptions?: Record<string, unknown>
|
|
345
369
|
}
|
|
346
370
|
|
|
347
371
|
/**
|
|
@@ -968,6 +992,15 @@ interface ResolvedAgentModel {
|
|
|
968
992
|
model: LanguageModel
|
|
969
993
|
modelId: string
|
|
970
994
|
providerId: string
|
|
995
|
+
/**
|
|
996
|
+
* Provider-specific `providerOptions` fragment carrying the mapped end-user
|
|
997
|
+
* safety identifier (undefined when no identifier or no provider mapping).
|
|
998
|
+
*/
|
|
999
|
+
providerOptions?: Record<string, unknown>
|
|
1000
|
+
/** Whether the resolved chat provider supports input moderation. */
|
|
1001
|
+
supportsInputModeration: boolean
|
|
1002
|
+
/** Resolved base URL (if any), reused by the moderation endpoint call. */
|
|
1003
|
+
baseURL?: string
|
|
971
1004
|
}
|
|
972
1005
|
|
|
973
1006
|
function resolveAgentModel(
|
|
@@ -979,6 +1012,7 @@ function resolveAgentModel(
|
|
|
979
1012
|
tenantOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
|
|
980
1013
|
requestOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
|
|
981
1014
|
tenantAllowlist?: TenantAllowlistSnapshot | null,
|
|
1015
|
+
endUserIdentifier?: string,
|
|
982
1016
|
): ResolvedAgentModel {
|
|
983
1017
|
const effectiveContainer = container ?? createContainer()
|
|
984
1018
|
const resolution = createModelFactory(effectiveContainer).resolveModel({
|
|
@@ -993,11 +1027,145 @@ function resolveAgentModel(
|
|
|
993
1027
|
tenantOverride: tenantOverride ?? undefined,
|
|
994
1028
|
requestOverride: requestOverride ?? undefined,
|
|
995
1029
|
tenantAllowlist: tenantAllowlist ?? null,
|
|
1030
|
+
endUserIdentifier,
|
|
996
1031
|
})
|
|
997
1032
|
return {
|
|
998
1033
|
model: resolution.model as LanguageModel,
|
|
999
1034
|
modelId: resolution.modelId,
|
|
1000
1035
|
providerId: resolution.providerId,
|
|
1036
|
+
providerOptions: resolution.providerOptions,
|
|
1037
|
+
supportsInputModeration: resolution.supportsInputModeration === true,
|
|
1038
|
+
baseURL: resolution.baseURL,
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Concatenates the text parts of the most recent user message. Only the new
|
|
1044
|
+
* user turn is moderated (prior history was screened on its own turn). Returns
|
|
1045
|
+
* an empty string when there is no user message or no text content.
|
|
1046
|
+
*/
|
|
1047
|
+
export function extractLatestUserText(messages: UIMessage[]): string {
|
|
1048
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
1049
|
+
const raw = messages[index] as unknown as {
|
|
1050
|
+
role?: string
|
|
1051
|
+
content?: string
|
|
1052
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
1053
|
+
}
|
|
1054
|
+
if (raw.role !== 'user') continue
|
|
1055
|
+
if (Array.isArray(raw.parts)) {
|
|
1056
|
+
return raw.parts
|
|
1057
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
1058
|
+
.map((part) => part.text as string)
|
|
1059
|
+
.join('\n')
|
|
1060
|
+
}
|
|
1061
|
+
return typeof raw.content === 'string' ? raw.content : ''
|
|
1062
|
+
}
|
|
1063
|
+
return ''
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export interface InputModerationGateParams {
|
|
1067
|
+
untrustedInput?: boolean
|
|
1068
|
+
supportsInputModeration: boolean
|
|
1069
|
+
userText: string
|
|
1070
|
+
service: ModerationService | null
|
|
1071
|
+
/**
|
|
1072
|
+
* Lazy resolver for the moderation provider's API key. Invoked only when the
|
|
1073
|
+
* gate actually proceeds (policy active, provider supports moderation, text
|
|
1074
|
+
* non-empty, service present) — so callers never pay for (or fail on) key
|
|
1075
|
+
* resolution on the common skip paths.
|
|
1076
|
+
*/
|
|
1077
|
+
resolveApiKey: () => string | null
|
|
1078
|
+
baseURL?: string
|
|
1079
|
+
moderationModel?: string
|
|
1080
|
+
perAgentOverride?: boolean | null
|
|
1081
|
+
tenantWideOverride?: boolean | null
|
|
1082
|
+
env?: EnvLookup
|
|
1083
|
+
/**
|
|
1084
|
+
* Best-effort side effect invoked with the flagged categories right before
|
|
1085
|
+
* the gate throws {@link AiModerationBlockedError}. Phase 3 wires the audit
|
|
1086
|
+
* insert + event emit here; it MUST NOT throw (failures are swallowed by the
|
|
1087
|
+
* caller) and MUST NOT block the rejection.
|
|
1088
|
+
*/
|
|
1089
|
+
onFlagged?: (categories: Record<string, { flagged: boolean; score: number }>) => void | Promise<void>
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Pre-loop input moderation gate. Runs the moderation check for the current
|
|
1094
|
+
* user turn before the model call. Decoupled from the registry/container so it
|
|
1095
|
+
* is unit-testable: the caller resolves the {@link ModerationService} and API
|
|
1096
|
+
* key. Throws {@link AiModerationBlockedError} when flagged; honors
|
|
1097
|
+
* fail-closed (enforced) vs fail-open (opt-in) on endpoint unavailability.
|
|
1098
|
+
*/
|
|
1099
|
+
export async function runInputModerationGate(params: InputModerationGateParams): Promise<void> {
|
|
1100
|
+
const policy = resolveModerationPolicy({
|
|
1101
|
+
untrustedInput: params.untrustedInput,
|
|
1102
|
+
perAgentOverride: params.perAgentOverride,
|
|
1103
|
+
tenantWideOverride: params.tenantWideOverride,
|
|
1104
|
+
env: params.env,
|
|
1105
|
+
})
|
|
1106
|
+
if (!isModerationActive(policy)) return
|
|
1107
|
+
// The resolved chat provider has no moderation endpoint — rely on its own
|
|
1108
|
+
// server-side filtering and skip the gate.
|
|
1109
|
+
if (!params.supportsInputModeration) return
|
|
1110
|
+
const text = params.userText.trim()
|
|
1111
|
+
if (!text) return
|
|
1112
|
+
|
|
1113
|
+
const failClosed = shouldFailClosed(policy)
|
|
1114
|
+
const failOpenOrThrow = (reason: string, error?: unknown): void => {
|
|
1115
|
+
if (failClosed) {
|
|
1116
|
+
if (error instanceof AiModerationUnavailableError) throw error
|
|
1117
|
+
throw new AiModerationUnavailableError(reason, error)
|
|
1118
|
+
}
|
|
1119
|
+
logger.warn('Input moderation unavailable; failing open for opt-in surface', { reason })
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (!params.service) {
|
|
1123
|
+
return failOpenOrThrow('moderation service is not available in the container')
|
|
1124
|
+
}
|
|
1125
|
+
const apiKey = params.resolveApiKey()
|
|
1126
|
+
if (!apiKey) {
|
|
1127
|
+
return failOpenOrThrow('no API key available for the moderation provider')
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
let result
|
|
1131
|
+
try {
|
|
1132
|
+
result = await params.service.checkInput({
|
|
1133
|
+
text,
|
|
1134
|
+
apiKey,
|
|
1135
|
+
baseURL: params.baseURL,
|
|
1136
|
+
model: params.moderationModel,
|
|
1137
|
+
})
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
if (error instanceof AiModerationUnavailableError) {
|
|
1140
|
+
return failOpenOrThrow(error.message, error)
|
|
1141
|
+
}
|
|
1142
|
+
throw error
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (result.flagged) {
|
|
1146
|
+
if (params.onFlagged) {
|
|
1147
|
+
try {
|
|
1148
|
+
await params.onFlagged(result.categories)
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
logger.error('Moderation flag side effect failed (rejection still applies)', { err: error })
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
throw new AiModerationBlockedError(result.categories)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Compute the tenant-salted end-user safety identifier for a chat turn,
|
|
1159
|
+
* best-effort. Returns undefined (never throws) so a derivation failure — e.g.
|
|
1160
|
+
* a missing base secret in a misconfigured environment — degrades to "no
|
|
1161
|
+
* identifier attached" instead of breaking the chat. Failures are logged.
|
|
1162
|
+
*/
|
|
1163
|
+
function resolveEndUserIdentifier(authContext: AiChatRequestContext): string | undefined {
|
|
1164
|
+
try {
|
|
1165
|
+
return computeEndUserIdentifier(authContext.tenantId, authContext.userId)
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
logger.warn('Failed to derive end-user safety identifier', { err: error })
|
|
1168
|
+
return undefined
|
|
1001
1169
|
}
|
|
1002
1170
|
}
|
|
1003
1171
|
|
|
@@ -1198,6 +1366,44 @@ async function resolveRuntimeModelOverride(
|
|
|
1198
1366
|
}
|
|
1199
1367
|
}
|
|
1200
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* Resolves the per-agent and tenant-wide `input_moderation` override values for
|
|
1371
|
+
* the moderation policy precedence. Best-effort and fail-open (returns nulls on
|
|
1372
|
+
* any error so the gate falls back to the env default). Returns the two values
|
|
1373
|
+
* separately because the precedence treats per-agent and tenant-wide as
|
|
1374
|
+
* distinct steps.
|
|
1375
|
+
*/
|
|
1376
|
+
async function resolveModerationOverrideValues(
|
|
1377
|
+
container: AwilixContainer | undefined,
|
|
1378
|
+
tenantId: string | null,
|
|
1379
|
+
organizationId: string | null,
|
|
1380
|
+
agentId: string,
|
|
1381
|
+
): Promise<{ perAgentOverride: boolean | null; tenantWideOverride: boolean | null }> {
|
|
1382
|
+
const empty = { perAgentOverride: null, tenantWideOverride: null }
|
|
1383
|
+
if (!tenantId || !container) return empty
|
|
1384
|
+
let em: EntityManager | null = null
|
|
1385
|
+
try {
|
|
1386
|
+
em = container.resolve<EntityManager>('em')
|
|
1387
|
+
} catch {
|
|
1388
|
+
em = null
|
|
1389
|
+
}
|
|
1390
|
+
if (!em) return empty
|
|
1391
|
+
try {
|
|
1392
|
+
const repo = new AiAgentRuntimeOverrideRepository(em)
|
|
1393
|
+
const [agentRow, tenantRow] = await Promise.all([
|
|
1394
|
+
repo.getExact({ tenantId, organizationId: organizationId ?? null, agentId }),
|
|
1395
|
+
repo.getDefault({ tenantId, organizationId: organizationId ?? null, agentId: null }),
|
|
1396
|
+
])
|
|
1397
|
+
return {
|
|
1398
|
+
perAgentOverride: agentRow?.inputModeration ?? null,
|
|
1399
|
+
tenantWideOverride: tenantRow?.inputModeration ?? null,
|
|
1400
|
+
}
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
logger.warn('Moderation override lookup failed; falling back to env default', { agentId, err: error })
|
|
1403
|
+
return empty
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1201
1407
|
/**
|
|
1202
1408
|
* Normalizes simple `{ role, content }` chat messages into the AI SDK
|
|
1203
1409
|
* `UIMessage` shape that `convertToModelMessages` requires. When the
|
|
@@ -1484,9 +1690,59 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1484
1690
|
tenantRuntimeOverride,
|
|
1485
1691
|
input.requestOverride,
|
|
1486
1692
|
tenantAllowlistSnapshot,
|
|
1693
|
+
resolveEndUserIdentifier(input.authContext),
|
|
1487
1694
|
)
|
|
1488
1695
|
const { model } = resolvedModel
|
|
1696
|
+
const modelProviderOptions = resolvedModel.providerOptions
|
|
1489
1697
|
const normalizedMessages = ensureUiMessageShape(input.messages)
|
|
1698
|
+
|
|
1699
|
+
// Pre-loop input moderation gate (spec 2026-06-04). Runs before the model
|
|
1700
|
+
// call on enabled/enforced surfaces where the provider supports moderation.
|
|
1701
|
+
// Throws AiModerationBlockedError (flagged) or AiModerationUnavailableError
|
|
1702
|
+
// (enforced + outage); both are surfaced by the SSE error path.
|
|
1703
|
+
let moderationService: ModerationService | null = null
|
|
1704
|
+
try {
|
|
1705
|
+
moderationService = input.container?.resolve<ModerationService>('moderationService') ?? null
|
|
1706
|
+
} catch {
|
|
1707
|
+
moderationService = null
|
|
1708
|
+
}
|
|
1709
|
+
// Read tenant overrides only when the provider can moderate and the agent is
|
|
1710
|
+
// not already enforced (untrustedInput short-circuits to enforced) — avoids a
|
|
1711
|
+
// DB round-trip on the common skip paths.
|
|
1712
|
+
const moderationOverrides =
|
|
1713
|
+
resolvedModel.supportsInputModeration && agent.untrustedInput !== true
|
|
1714
|
+
? await resolveModerationOverrideValues(
|
|
1715
|
+
input.container,
|
|
1716
|
+
input.authContext.tenantId,
|
|
1717
|
+
input.authContext.organizationId,
|
|
1718
|
+
agent.id,
|
|
1719
|
+
)
|
|
1720
|
+
: { perAgentOverride: null, tenantWideOverride: null }
|
|
1721
|
+
await runInputModerationGate({
|
|
1722
|
+
untrustedInput: agent.untrustedInput,
|
|
1723
|
+
supportsInputModeration: resolvedModel.supportsInputModeration,
|
|
1724
|
+
perAgentOverride: moderationOverrides.perAgentOverride,
|
|
1725
|
+
tenantWideOverride: moderationOverrides.tenantWideOverride,
|
|
1726
|
+
userText: extractLatestUserText(normalizedMessages),
|
|
1727
|
+
service: moderationService,
|
|
1728
|
+
resolveApiKey: () => llmProviderRegistry.get(resolvedModel.providerId)?.resolveApiKey() ?? null,
|
|
1729
|
+
baseURL: resolvedModel.baseURL,
|
|
1730
|
+
moderationModel: process.env.OM_AI_MODERATION_MODEL,
|
|
1731
|
+
onFlagged: (categories) =>
|
|
1732
|
+
recordModerationFlag(
|
|
1733
|
+
{
|
|
1734
|
+
tenantId: input.authContext.tenantId,
|
|
1735
|
+
organizationId: input.authContext.organizationId,
|
|
1736
|
+
agentId: agent.id,
|
|
1737
|
+
userId: input.authContext.userId,
|
|
1738
|
+
providerId: resolvedModel.providerId,
|
|
1739
|
+
modelId: resolvedModel.modelId,
|
|
1740
|
+
categories,
|
|
1741
|
+
},
|
|
1742
|
+
input.container,
|
|
1743
|
+
),
|
|
1744
|
+
})
|
|
1745
|
+
|
|
1490
1746
|
const hydratedMessages = attachAttachmentsToMessages(normalizedMessages, resolvedAttachments)
|
|
1491
1747
|
const modelMessages = await convertToModelMessages(hydratedMessages)
|
|
1492
1748
|
|
|
@@ -1579,6 +1835,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1579
1835
|
: {}),
|
|
1580
1836
|
...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
|
|
1581
1837
|
...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
|
|
1838
|
+
...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions as never } : {}),
|
|
1582
1839
|
}
|
|
1583
1840
|
builtToolLoopAgent = new ToolLoopAgent(agentSettings)
|
|
1584
1841
|
}
|
|
@@ -1600,6 +1857,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1600
1857
|
toolChoice: sdkToolChoice,
|
|
1601
1858
|
abortSignal: abortController.signal,
|
|
1602
1859
|
finalizeLoopTrace: () => loopTraceCollector.finalize(budgetEnforcer.abortReason),
|
|
1860
|
+
...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions } : {}),
|
|
1603
1861
|
...(builtToolLoopAgent !== undefined ? { toolLoopAgent: builtToolLoopAgent } : {}),
|
|
1604
1862
|
}
|
|
1605
1863
|
|
|
@@ -1672,6 +1930,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
|
|
|
1672
1930
|
experimental_repairToolCall: effectiveLoop.repairToolCall as never,
|
|
1673
1931
|
...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
|
|
1674
1932
|
...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
|
|
1933
|
+
...(modelProviderOptions !== undefined ? { providerOptions: modelProviderOptions as never } : {}),
|
|
1675
1934
|
abortSignal: abortController.signal,
|
|
1676
1935
|
})
|
|
1677
1936
|
if (wallClockTimer !== undefined) {
|
|
@@ -384,6 +384,16 @@ export interface AiAgentDefinition {
|
|
|
384
384
|
uiParts?: string[]
|
|
385
385
|
readOnly?: boolean
|
|
386
386
|
mutationPolicy?: AiAgentMutationPolicy
|
|
387
|
+
/**
|
|
388
|
+
* Marks this agent as accepting untrusted end-user input (e.g. a customer
|
|
389
|
+
* portal or public-widget surface). When `true`, input pre-moderation is
|
|
390
|
+
* ENFORCED on every turn wherever the resolved chat provider supports it —
|
|
391
|
+
* tenants cannot disable it. Other agents default to off and are tenant
|
|
392
|
+
* opt-in. Additive + optional; omitting it preserves today's behavior.
|
|
393
|
+
*
|
|
394
|
+
* Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
|
|
395
|
+
*/
|
|
396
|
+
untrustedInput?: boolean
|
|
387
397
|
/**
|
|
388
398
|
* @deprecated Use `loop.maxSteps` instead. Honored as alias when `loop` is
|
|
389
399
|
* omitted. When both `maxSteps` and `loop.maxSteps` are specified, `loop.maxSteps`
|
|
@@ -2,7 +2,7 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
2
2
|
import type { AwilixContainer } from 'awilix'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
5
|
-
import {
|
|
5
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
6
6
|
|
|
7
7
|
const logger = createLogger('ai_assistant')
|
|
8
8
|
|
|
@@ -137,20 +137,11 @@ export function hasRequiredFeatures(
|
|
|
137
137
|
isSuperAdmin: boolean,
|
|
138
138
|
rbacService?: RbacService
|
|
139
139
|
): boolean {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return rbacService.hasAllFeatures(requiredFeatures, userFeatures)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Fallback for cases without rbacService: delegate to the canonical
|
|
149
|
-
// wildcard-aware matcher so this path stays consistent with
|
|
150
|
-
// RbacService.hasAllFeatures (which uses the same helper). The previous
|
|
151
|
-
// bespoke loop rejected a bare-segment requirement (e.g. 'entities')
|
|
152
|
-
// against an 'entities.*' grant, diverging from the canonical matcher.
|
|
153
|
-
return hasAllFeatures(requiredFeatures, userFeatures)
|
|
140
|
+
void rbacService
|
|
141
|
+
return authorizeFeatures(requiredFeatures ?? [], {
|
|
142
|
+
grantedFeatures: userFeatures,
|
|
143
|
+
unrestricted: isSuperAdmin,
|
|
144
|
+
})
|
|
154
145
|
}
|
|
155
146
|
|
|
156
147
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { ReferenceKind, type MikroORM } from '@mikro-orm/core'
|
|
9
9
|
import type { PostgreSqlDriver } from '@mikro-orm/postgresql'
|
|
10
|
+
import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Relationship types mapped from MikroORM reference kinds.
|
|
@@ -86,13 +87,12 @@ function getSimpleTypeName(type: string | ((...args: unknown[]) => unknown) | un
|
|
|
86
87
|
* Extract the entity graph from MikroORM metadata.
|
|
87
88
|
*/
|
|
88
89
|
export async function extractEntityGraph(orm: MikroORM<PostgreSqlDriver>): Promise<EntityGraph> {
|
|
89
|
-
const
|
|
90
|
-
const allMetadata = metadata.getAll()
|
|
90
|
+
const allMetadata = listEntityMetadata(orm)
|
|
91
91
|
|
|
92
92
|
const nodes: EntityNode[] = []
|
|
93
93
|
const edges: EntityTriple[] = []
|
|
94
94
|
|
|
95
|
-
for (const
|
|
95
|
+
for (const entityMeta of allMetadata) {
|
|
96
96
|
// Skip abstract entities and embeddables
|
|
97
97
|
if (entityMeta.abstract || entityMeta.embeddable) continue
|
|
98
98
|
|
|
@@ -90,6 +90,16 @@ export function createAnthropicAdapter(): LlmProvider {
|
|
|
90
90
|
return envKeys[0]
|
|
91
91
|
},
|
|
92
92
|
|
|
93
|
+
mapEndUserIdentifier(identifier: string): Record<string, unknown> {
|
|
94
|
+
// Anthropic Messages API accepts `metadata.user_id` so abuse enforcement
|
|
95
|
+
// can target a single hashed end user instead of the org key. The key MUST
|
|
96
|
+
// be the AI SDK provider-option name `userId` (camelCase) — the SDK
|
|
97
|
+
// translates it to the `metadata.user_id` request-body field and strips
|
|
98
|
+
// unknown providerOptions keys, so a snake_case key would be dropped and
|
|
99
|
+
// never reach Anthropic.
|
|
100
|
+
return { anthropic: { metadata: { userId: identifier } } }
|
|
101
|
+
},
|
|
102
|
+
|
|
93
103
|
createModel(options: LlmCreateModelOptions): unknown {
|
|
94
104
|
const anthropic = createAnthropic({
|
|
95
105
|
apiKey: options.apiKey,
|
|
@@ -62,6 +62,23 @@ export interface OpenAICompatiblePreset {
|
|
|
62
62
|
* The first non-empty value wins and overrides {@link baseURL}.
|
|
63
63
|
*/
|
|
64
64
|
baseURLEnvKeys?: readonly string[]
|
|
65
|
+
/**
|
|
66
|
+
* When `true`, the provider exposes OpenAI's `/v1/moderations` endpoint and
|
|
67
|
+
* the runtime may run input pre-moderation through it. Only the native
|
|
68
|
+
* `openai` preset sets this — OpenAI-compatible backends (Groq, DeepInfra,
|
|
69
|
+
* Together, …) do not implement the moderations endpoint, so they leave it
|
|
70
|
+
* unset and rely on their own server-side filtering.
|
|
71
|
+
*/
|
|
72
|
+
supportsInputModeration?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* When `true`, the provider maps the runtime end-user safety identifier into
|
|
75
|
+
* OpenAI's per-request `safetyIdentifier` provider option. Only the native
|
|
76
|
+
* `openai` preset sets this — `safety_identifier` is an OpenAI-specific body
|
|
77
|
+
* field, and OpenAI-compatible/self-hosted backends (Groq, DeepInfra, Azure,
|
|
78
|
+
* Ollama, LM Studio, …) may reject the unknown parameter, so they leave it
|
|
79
|
+
* unset and send no identifier (matching pre-feature behavior).
|
|
80
|
+
*/
|
|
81
|
+
supportsEndUserSafetyIdentifier?: boolean
|
|
65
82
|
/**
|
|
66
83
|
* True when this gateway's model ids are `vendor/model` strings (OpenRouter,
|
|
67
84
|
* Requesty, LiteLLM). Surfaced on the resolved `LlmProvider` so the model
|
|
@@ -116,12 +133,13 @@ export function createOpenAICompatibleProvider(
|
|
|
116
133
|
return preset.baseURL
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
|
|
136
|
+
const provider: LlmProvider = {
|
|
120
137
|
id: preset.id,
|
|
121
138
|
name: preset.name,
|
|
122
139
|
envKeys: preset.envKeys,
|
|
123
140
|
defaultModel: preset.defaultModel,
|
|
124
141
|
defaultModels: preset.defaultModels,
|
|
142
|
+
supportsInputModeration: preset.supportsInputModeration ?? false,
|
|
125
143
|
usesVendorPrefixedModelIds: preset.usesVendorPrefixedModelIds ?? false,
|
|
126
144
|
|
|
127
145
|
isConfigured(env?: EnvLookup): boolean {
|
|
@@ -151,4 +169,17 @@ export function createOpenAICompatibleProvider(
|
|
|
151
169
|
return openai(options.modelId)
|
|
152
170
|
},
|
|
153
171
|
}
|
|
172
|
+
|
|
173
|
+
// Only the native OpenAI preset maps the end-user safety identifier. The key
|
|
174
|
+
// MUST be the AI SDK provider-option name `safetyIdentifier` (camelCase) — the
|
|
175
|
+
// SDK translates it to the `safety_identifier` request-body field and strips
|
|
176
|
+
// any unknown providerOptions keys, so a snake_case key would be silently
|
|
177
|
+
// dropped and never reach OpenAI.
|
|
178
|
+
if (preset.supportsEndUserSafetyIdentifier) {
|
|
179
|
+
provider.mapEndUserIdentifier = (identifier: string): Record<string, unknown> => ({
|
|
180
|
+
openai: { safetyIdentifier: identifier },
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return provider
|
|
154
185
|
}
|
|
@@ -217,6 +217,16 @@ export interface AiModelFactoryInput {
|
|
|
217
217
|
* intersecting at resolution time.
|
|
218
218
|
*/
|
|
219
219
|
tenantAllowlist?: TenantAllowlistSnapshot | null
|
|
220
|
+
/**
|
|
221
|
+
* Optional opaque end-user identifier (tenant-salted HMAC). When set and the
|
|
222
|
+
* resolved provider implements {@link LlmProvider.mapEndUserIdentifier}, the
|
|
223
|
+
* factory returns the provider-specific `providerOptions` fragment on the
|
|
224
|
+
* resolution so the runtime can attach it to the model call. No-op for
|
|
225
|
+
* providers without a mapping or when no identifier is supplied.
|
|
226
|
+
*
|
|
227
|
+
* @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md
|
|
228
|
+
*/
|
|
229
|
+
endUserIdentifier?: string
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
/**
|
|
@@ -268,6 +278,20 @@ export interface AiModelResolution {
|
|
|
268
278
|
originalModelId: string
|
|
269
279
|
reason: string
|
|
270
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Provider-specific `providerOptions` fragment produced by mapping the input
|
|
283
|
+
* {@link AiModelFactoryInput.endUserIdentifier} through the resolved
|
|
284
|
+
* provider's {@link LlmProvider.mapEndUserIdentifier}. Undefined when no
|
|
285
|
+
* identifier was supplied or the provider has no mapping. The runtime merges
|
|
286
|
+
* this into the AI SDK `providerOptions` for the model call.
|
|
287
|
+
*/
|
|
288
|
+
providerOptions?: Record<string, unknown>
|
|
289
|
+
/**
|
|
290
|
+
* Mirror of the resolved provider's {@link LlmProvider.supportsInputModeration}
|
|
291
|
+
* capability flag. The runtime uses this to decide whether to run the input
|
|
292
|
+
* pre-moderation gate before the model call.
|
|
293
|
+
*/
|
|
294
|
+
supportsInputModeration?: boolean
|
|
271
295
|
}
|
|
272
296
|
|
|
273
297
|
/**
|
|
@@ -776,17 +800,26 @@ export function createModelFactory(
|
|
|
776
800
|
)
|
|
777
801
|
}
|
|
778
802
|
|
|
803
|
+
const endUserIdentifier = normalizeOverride(input.endUserIdentifier)
|
|
804
|
+
const providerOptions =
|
|
805
|
+
endUserIdentifier && finalProvider.mapEndUserIdentifier
|
|
806
|
+
? finalProvider.mapEndUserIdentifier(endUserIdentifier)
|
|
807
|
+
: undefined
|
|
808
|
+
|
|
779
809
|
const model = finalProvider.createModel({
|
|
780
810
|
modelId: finalModelId,
|
|
781
811
|
apiKey: finalApiKey,
|
|
782
812
|
baseURL: resolvedBaseURL,
|
|
813
|
+
...(endUserIdentifier ? { endUserIdentifier } : {}),
|
|
783
814
|
})
|
|
784
815
|
return {
|
|
785
816
|
model,
|
|
786
817
|
modelId: finalModelId,
|
|
787
818
|
providerId: finalProvider.id,
|
|
788
819
|
source: finalSource,
|
|
820
|
+
supportsInputModeration: finalProvider.supportsInputModeration === true,
|
|
789
821
|
...(resolvedBaseURL !== undefined ? { baseURL: resolvedBaseURL } : {}),
|
|
822
|
+
...(providerOptions !== undefined ? { providerOptions } : {}),
|
|
790
823
|
...(allowlistResult.fallback
|
|
791
824
|
? {
|
|
792
825
|
allowlistFallback: {
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|