@open-mercato/ai-assistant 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -29
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
  8. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
  12. package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/settings/route.js +34 -13
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +106 -0
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/data/entities.js +42 -0
  26. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  27. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  28. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  30. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  31. package/dist/modules/ai_assistant/di.js +6 -2
  32. package/dist/modules/ai_assistant/di.js.map +2 -2
  33. package/dist/modules/ai_assistant/events.js +6 -0
  34. package/dist/modules/ai_assistant/events.js.map +2 -2
  35. package/dist/modules/ai_assistant/i18n/de.json +26 -0
  36. package/dist/modules/ai_assistant/i18n/en.json +26 -0
  37. package/dist/modules/ai_assistant/i18n/es.json +26 -0
  38. package/dist/modules/ai_assistant/i18n/pl.json +26 -0
  39. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  40. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  41. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  42. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  43. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  44. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  45. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  47. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  48. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  49. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  50. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  51. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  52. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  53. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  54. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  55. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  56. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  57. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  59. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  60. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  61. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  62. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  63. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  64. package/generated/entities.ids.generated.ts +2 -1
  65. package/generated/entity-fields-registry.ts +13 -1
  66. package/package.json +6 -6
  67. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  68. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  69. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  70. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  71. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  72. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  73. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  74. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  75. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  76. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +146 -0
  77. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  78. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  79. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  80. package/src/modules/ai_assistant/data/entities.ts +56 -0
  81. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  82. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  83. package/src/modules/ai_assistant/di.ts +5 -1
  84. package/src/modules/ai_assistant/events.ts +21 -0
  85. package/src/modules/ai_assistant/i18n/de.json +26 -0
  86. package/src/modules/ai_assistant/i18n/en.json +26 -0
  87. package/src/modules/ai_assistant/i18n/es.json +26 -0
  88. package/src/modules/ai_assistant/i18n/pl.json +26 -0
  89. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  90. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  91. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  92. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  93. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  94. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  95. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  96. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  97. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  98. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  99. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  100. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  101. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  102. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  103. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  104. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  105. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  106. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  107. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  108. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  109. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -17,6 +17,7 @@ import {
17
17
  isOpenCodeProviderConfigured,
18
18
  } from '@open-mercato/shared/lib/ai/opencode-provider'
19
19
  import { AiAgentRuntimeOverrideRepository, AiAgentRuntimeOverrideValidationError } from '../../data/repositories/AiAgentRuntimeOverrideRepository'
20
+ import { resolveModerationPolicy } from '../../lib/moderation-policy'
20
21
  import { AiTenantModelAllowlistRepository } from '../../data/repositories/AiTenantModelAllowlistRepository'
21
22
  import { isBaseurlAllowlisted, readBaseurlAllowlist } from '../../lib/baseurl-allowlist'
22
23
  import { loadAgentRegistry, listAgents } from '../../lib/agent-registry'
@@ -59,6 +60,9 @@ const runtimeOverrideUpsertSchema = z.object({
59
60
  allowedOverrideModelsByProvider: z
60
61
  .record(z.string().min(1).max(64), z.array(z.string().min(1).max(256)))
61
62
  .optional(),
63
+ // Input moderation override: true = on, false = off, null = inherit.
64
+ // Spec 2026-06-04-ai-input-moderation-and-safety-identifiers.
65
+ inputModeration: z.boolean().nullable().optional(),
62
66
  })
63
67
 
64
68
  const runtimeOverrideClearSchema = z.object({
@@ -248,6 +252,7 @@ export async function GET(req: NextRequest) {
248
252
  const em = container.resolve<EntityManager>('em')
249
253
  const repo = new AiAgentRuntimeOverrideRepository(em)
250
254
  const overrideRow = await repo.getDefault({ tenantId, organizationId, agentId: null })
255
+ const tenantWideInputModeration = overrideRow?.inputModeration ?? null
251
256
  if (overrideRow) {
252
257
  tenantOverride = {
253
258
  providerId: overrideRow.providerId ?? null,
@@ -363,6 +368,20 @@ export async function GET(req: NextRequest) {
363
368
  modelId: agentResolution.modelId,
364
369
  baseURL: agentResolution.baseURL ?? null,
365
370
  source: agentResolution.source,
371
+ moderation: {
372
+ // `untrustedInput` agents enforce moderation; the UI renders a
373
+ // non-editable "Enforced" badge for them.
374
+ enforced: agent.untrustedInput === true,
375
+ // Per-agent control value: null = inherit, true = on, false = off.
376
+ override: agentOverrideRow?.inputModeration ?? null,
377
+ // Resolved runtime policy (enforced | on | off) after precedence.
378
+ effective: resolveModerationPolicy({
379
+ untrustedInput: agent.untrustedInput,
380
+ perAgentOverride: agentOverrideRow?.inputModeration ?? null,
381
+ tenantWideOverride: tenantWideInputModeration,
382
+ env,
383
+ }),
384
+ },
366
385
  }
367
386
  })
368
387
  agentResolutions = await Promise.all(agentResolutionPromises)
@@ -691,11 +710,11 @@ export async function PUT(req: NextRequest) {
691
710
  try {
692
711
  const container = await createRequestContainer()
693
712
  const rbacService = container.resolve<RbacService>('rbacService')
694
- const acl = await rbacService.loadAcl(auth.sub, {
695
- tenantId: auth.tenantId,
696
- organizationId: auth.orgId,
697
- })
698
- const canManage = acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
713
+ const canManage = await rbacService.userHasAllFeatures(
714
+ auth.sub,
715
+ ['ai_assistant.settings.manage'],
716
+ { tenantId: auth.tenantId, organizationId: auth.orgId },
717
+ )
699
718
  if (!canManage) {
700
719
  return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
701
720
  }
@@ -719,6 +738,9 @@ export async function PUT(req: NextRequest) {
719
738
  ...(allowedOverrideModelsByProvider !== undefined
720
739
  ? { allowedOverrideModelsByProvider }
721
740
  : {}),
741
+ ...(Object.prototype.hasOwnProperty.call(bodyResult.data, 'inputModeration')
742
+ ? { inputModeration: bodyResult.data.inputModeration ?? null }
743
+ : {}),
722
744
  }
723
745
  const row = await repo.upsertDefault(
724
746
  upsertInput,
@@ -734,6 +756,7 @@ export async function PUT(req: NextRequest) {
734
756
  baseURL: row.baseUrl,
735
757
  allowedOverrideProviders: row.allowedOverrideProviders ?? null,
736
758
  allowedOverrideModelsByProvider: row.allowedOverrideModelsByProvider ?? {},
759
+ inputModeration: row.inputModeration ?? null,
737
760
  updatedAt: row.updatedAt,
738
761
  })
739
762
  } catch (error) {
@@ -777,11 +800,11 @@ export async function DELETE(req: NextRequest) {
777
800
  try {
778
801
  const container = await createRequestContainer()
779
802
  const rbacService = container.resolve<RbacService>('rbacService')
780
- const acl = await rbacService.loadAcl(auth.sub, {
781
- tenantId: auth.tenantId,
782
- organizationId: auth.orgId,
783
- })
784
- const canManage = acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
803
+ const canManage = await rbacService.userHasAllFeatures(
804
+ auth.sub,
805
+ ['ai_assistant.settings.manage'],
806
+ { tenantId: auth.tenantId, organizationId: auth.orgId },
807
+ )
785
808
  if (!canManage) {
786
809
  return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
787
810
  }
@@ -121,6 +121,14 @@ type AgentResolution = {
121
121
  modelId: string
122
122
  baseURL: string | null
123
123
  source: string
124
+ moderation?: {
125
+ /** `untrustedInput` agents enforce moderation (non-editable). */
126
+ enforced: boolean
127
+ /** Per-agent control value: null = inherit, true = on, false = off. */
128
+ override: boolean | null
129
+ /** Resolved runtime policy after the precedence chain. */
130
+ effective: 'enforced' | 'on' | 'off'
131
+ }
124
132
  }
125
133
 
126
134
  type TenantAllowlist = {
@@ -875,6 +883,142 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
875
883
  )
876
884
  }
877
885
 
886
+ type ModerationControlValue = 'inherit' | 'on' | 'off'
887
+
888
+ function ModerationSection({ agent }: { agent: AgentSettings }) {
889
+ const t = useT()
890
+ const queryClient = useQueryClient()
891
+ const settingsQuery = useQuery<RuntimeSettingsResponse>({
892
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
893
+ queryFn: fetchRuntimeSettings,
894
+ retry: false,
895
+ })
896
+ const agentResolution = settingsQuery.data?.agents.find((entry) => entry.agentId === agent.id) ?? null
897
+ const moderation = agentResolution?.moderation ?? null
898
+ const enforced = moderation?.enforced === true
899
+ const currentValue: ModerationControlValue =
900
+ moderation?.override === true ? 'on' : moderation?.override === false ? 'off' : 'inherit'
901
+ const [isSaving, setIsSaving] = React.useState(false)
902
+ const { runMutation } = useGuardedMutation({ contextId: `ai-agent-moderation-${agent.id}` })
903
+
904
+ const save = React.useCallback(
905
+ async (value: ModerationControlValue) => {
906
+ if (isSaving || enforced) return
907
+ setIsSaving(true)
908
+ try {
909
+ const inputModeration = value === 'inherit' ? null : value === 'on'
910
+ await runMutation({
911
+ operation: async () => {
912
+ const { ok, status, result } = await apiCall<{ error?: string }>(
913
+ '/api/ai_assistant/settings',
914
+ {
915
+ method: 'PUT',
916
+ headers: { 'content-type': 'application/json' },
917
+ credentials: 'include',
918
+ body: JSON.stringify({ agentId: agent.id, inputModeration }),
919
+ },
920
+ )
921
+ if (!ok) {
922
+ throw new Error(result?.error ?? `[internal] Failed to save moderation setting (${status}).`)
923
+ }
924
+ },
925
+ context: {},
926
+ })
927
+ flash(t('ai_assistant.agents.moderation.saved', 'Input moderation setting saved.'), 'success')
928
+ await queryClient.invalidateQueries({
929
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
930
+ })
931
+ } catch (err) {
932
+ flash(err instanceof Error ? err.message : String(err), 'error')
933
+ } finally {
934
+ setIsSaving(false)
935
+ }
936
+ },
937
+ [agent.id, enforced, isSaving, queryClient, runMutation, t],
938
+ )
939
+
940
+ return (
941
+ <section
942
+ className="rounded-lg border border-border bg-background p-4"
943
+ data-ai-agent-moderation={agent.id}
944
+ >
945
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
946
+ <div className="flex min-w-0 items-start gap-2">
947
+ <ShieldAlert className="size-4 text-muted-foreground" aria-hidden />
948
+ <div className="min-w-0">
949
+ <h3 className="text-sm font-semibold">
950
+ {t('ai_assistant.agents.moderation.title', 'Input moderation')}
951
+ </h3>
952
+ <p className="text-xs text-muted-foreground">
953
+ {t(
954
+ 'ai_assistant.agents.moderation.subtitle',
955
+ 'Screen user input for unsafe content before the model runs. Applies only when the resolved provider supports moderation.',
956
+ )}
957
+ </p>
958
+ </div>
959
+ </div>
960
+ {moderation ? (
961
+ <StatusBadge
962
+ variant={moderation.effective === 'off' ? 'neutral' : 'info'}
963
+ dot
964
+ data-ai-agent-moderation-effective
965
+ >
966
+ {moderation.effective === 'enforced'
967
+ ? t('ai_assistant.agents.moderation.effectiveEnforced', 'Enforced')
968
+ : moderation.effective === 'on'
969
+ ? t('ai_assistant.agents.moderation.effectiveOn', 'On')
970
+ : t('ai_assistant.agents.moderation.effectiveOff', 'Off')}
971
+ </StatusBadge>
972
+ ) : null}
973
+ </header>
974
+
975
+ <div className="mt-3">
976
+ {enforced ? (
977
+ <div className="flex flex-wrap items-center gap-2" data-ai-agent-moderation-enforced>
978
+ <StatusBadge variant="warning" dot>
979
+ {t('ai_assistant.agents.moderation.enforcedBadge', 'Enforced')}
980
+ </StatusBadge>
981
+ <p className="text-xs text-muted-foreground">
982
+ {t(
983
+ 'ai_assistant.agents.moderation.enforcedHint',
984
+ 'This agent accepts untrusted input, so moderation is always on and cannot be disabled here.',
985
+ )}
986
+ </p>
987
+ </div>
988
+ ) : (
989
+ <div className="max-w-xs">
990
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
991
+ {t('ai_assistant.agents.moderation.controlLabel', 'Moderation')}
992
+ </span>
993
+ <div className="mt-1">
994
+ <Select
995
+ value={currentValue}
996
+ onValueChange={(value) => save(value as ModerationControlValue)}
997
+ disabled={isSaving || !agentResolution}
998
+ >
999
+ <SelectTrigger aria-label={t('ai_assistant.agents.moderation.controlLabel', 'Moderation')}>
1000
+ <SelectValue />
1001
+ </SelectTrigger>
1002
+ <SelectContent>
1003
+ <SelectItem value="inherit">
1004
+ {t('ai_assistant.agents.moderation.inheritLabel', 'Inherit')}
1005
+ </SelectItem>
1006
+ <SelectItem value="on">
1007
+ {t('ai_assistant.agents.moderation.onLabel', 'On')}
1008
+ </SelectItem>
1009
+ <SelectItem value="off">
1010
+ {t('ai_assistant.agents.moderation.offLabel', 'Off')}
1011
+ </SelectItem>
1012
+ </SelectContent>
1013
+ </Select>
1014
+ </div>
1015
+ </div>
1016
+ )}
1017
+ </div>
1018
+ </section>
1019
+ )
1020
+ }
1021
+
878
1022
  function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
879
1023
  const t = useT()
880
1024
  const queryClient = useQueryClient()
@@ -2063,6 +2207,8 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2063
2207
 
2064
2208
  <AgentModelOverrideSection agent={agent} />
2065
2209
 
2210
+ <ModerationSection agent={agent} />
2211
+
2066
2212
  <MutationPolicySection agent={agent} />
2067
2213
 
2068
2214
  <LoopPolicySection agent={agent} />
@@ -0,0 +1,186 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useQuery } from '@tanstack/react-query'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { Label } from '@open-mercato/ui/primitives/label'
11
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
12
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
13
+
14
+ const PAGE_SIZE = 50
15
+
16
+ type ModerationCategory = { flagged: boolean; score: number }
17
+
18
+ type ModerationFlagRow = {
19
+ id: string
20
+ tenantId: string
21
+ organizationId: string | null
22
+ agentId: string
23
+ userId: string
24
+ providerId: string
25
+ modelId: string
26
+ categories: Record<string, ModerationCategory>
27
+ createdAt: string
28
+ }
29
+
30
+ type ModerationFlagsResponse = {
31
+ items: ModerationFlagRow[]
32
+ total: number
33
+ page: number
34
+ pageSize: number
35
+ }
36
+
37
+ async function fetchModerationFlags(params: {
38
+ page: number
39
+ from: string
40
+ to: string
41
+ agentId: string
42
+ userId: string
43
+ }): Promise<ModerationFlagsResponse> {
44
+ const search = new URLSearchParams()
45
+ search.set('page', String(params.page))
46
+ search.set('pageSize', String(PAGE_SIZE))
47
+ if (params.from) search.set('from', params.from)
48
+ if (params.to) search.set('to', params.to)
49
+ if (params.agentId) search.set('agentId', params.agentId)
50
+ if (params.userId) search.set('userId', params.userId)
51
+ const { result, status } = await apiCallOrThrow<ModerationFlagsResponse>(
52
+ `/api/ai_assistant/moderation-flags?${search.toString()}`,
53
+ undefined,
54
+ { errorMessage: 'Failed to load moderation flags' },
55
+ )
56
+ if (!result) throw new Error(`Failed to load moderation flags (${status})`)
57
+ return result
58
+ }
59
+
60
+ function flaggedCategoryNames(categories: Record<string, ModerationCategory>): string[] {
61
+ return Object.entries(categories)
62
+ .filter(([, value]) => value.flagged)
63
+ .map(([name]) => name)
64
+ }
65
+
66
+ export function AiModerationFlagsPageClient() {
67
+ const t = useT()
68
+ const [page, setPage] = React.useState(1)
69
+ const [from, setFrom] = React.useState('')
70
+ const [to, setTo] = React.useState('')
71
+ const [agentId, setAgentId] = React.useState('')
72
+ const [userId, setUserId] = React.useState('')
73
+ const [applied, setApplied] = React.useState({ from: '', to: '', agentId: '', userId: '' })
74
+
75
+ const query = useQuery<ModerationFlagsResponse>({
76
+ queryKey: ['ai_assistant', 'moderation_flags', page, applied],
77
+ queryFn: () => fetchModerationFlags({ page, ...applied }),
78
+ retry: false,
79
+ })
80
+
81
+ const applyFilters = React.useCallback(() => {
82
+ setPage(1)
83
+ setApplied({ from, to, agentId, userId })
84
+ }, [from, to, agentId, userId])
85
+
86
+ const columns = React.useMemo<ColumnDef<ModerationFlagRow, unknown>[]>(
87
+ () => [
88
+ {
89
+ accessorKey: 'agentId',
90
+ header: t('ai_assistant.moderationFlags.columns.agent', 'Agent'),
91
+ },
92
+ {
93
+ accessorKey: 'userId',
94
+ header: t('ai_assistant.moderationFlags.columns.user', 'User'),
95
+ meta: { truncate: true, maxWidth: 220 },
96
+ },
97
+ {
98
+ accessorKey: 'categories',
99
+ header: t('ai_assistant.moderationFlags.columns.categories', 'Categories'),
100
+ cell: ({ row }) => {
101
+ const names = flaggedCategoryNames(row.original.categories)
102
+ if (names.length === 0) {
103
+ return <span className="text-muted-foreground">—</span>
104
+ }
105
+ return (
106
+ <div className="flex flex-wrap gap-1">
107
+ {names.map((name) => (
108
+ <StatusBadge key={name} variant="error" dot>
109
+ {name}
110
+ </StatusBadge>
111
+ ))}
112
+ </div>
113
+ )
114
+ },
115
+ },
116
+ {
117
+ accessorKey: 'createdAt',
118
+ header: t('ai_assistant.moderationFlags.columns.createdAt', 'Flagged at'),
119
+ cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
120
+ },
121
+ ],
122
+ [t],
123
+ )
124
+
125
+ const total = query.data?.total ?? 0
126
+
127
+ return (
128
+ <div className="flex flex-col gap-4">
129
+ <div>
130
+ <h2 className="text-base font-semibold">
131
+ {t('ai_assistant.moderationFlags.title', 'Moderation flags')}
132
+ </h2>
133
+ <p className="text-xs text-muted-foreground">
134
+ {t(
135
+ 'ai_assistant.moderationFlags.subtitle',
136
+ 'Inputs blocked by the content safety filter. Category flags and scores only — no prompt content is stored.',
137
+ )}
138
+ </p>
139
+ </div>
140
+
141
+ <div className="flex flex-wrap items-end gap-3">
142
+ <div className="flex flex-col gap-1">
143
+ <Label htmlFor="moderation-from">{t('ai_assistant.moderationFlags.filters.from', 'From')}</Label>
144
+ <Input id="moderation-from" type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
145
+ </div>
146
+ <div className="flex flex-col gap-1">
147
+ <Label htmlFor="moderation-to">{t('ai_assistant.moderationFlags.filters.to', 'To')}</Label>
148
+ <Input id="moderation-to" type="date" value={to} onChange={(e) => setTo(e.target.value)} />
149
+ </div>
150
+ <div className="flex flex-col gap-1">
151
+ <Label htmlFor="moderation-agent">{t('ai_assistant.moderationFlags.columns.agent', 'Agent')}</Label>
152
+ <Input
153
+ id="moderation-agent"
154
+ value={agentId}
155
+ onChange={(e) => setAgentId(e.target.value)}
156
+ placeholder={t('ai_assistant.moderationFlags.filters.agentPlaceholder', 'module.agent')}
157
+ />
158
+ </div>
159
+ <div className="flex flex-col gap-1">
160
+ <Label htmlFor="moderation-user">{t('ai_assistant.moderationFlags.columns.user', 'User')}</Label>
161
+ <Input id="moderation-user" value={userId} onChange={(e) => setUserId(e.target.value)} />
162
+ </div>
163
+ <Button type="button" variant="outline" onClick={applyFilters}>
164
+ {t('ai_assistant.moderationFlags.filters.apply', 'Apply')}
165
+ </Button>
166
+ </div>
167
+
168
+ <DataTable<ModerationFlagRow>
169
+ columns={columns}
170
+ data={query.data?.items ?? []}
171
+ isLoading={query.isLoading}
172
+ error={query.error ? (query.error as Error).message : undefined}
173
+ emptyState={t('ai_assistant.moderationFlags.empty', 'No flagged messages.')}
174
+ pagination={{
175
+ page,
176
+ pageSize: PAGE_SIZE,
177
+ total,
178
+ totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
179
+ onPageChange: setPage,
180
+ }}
181
+ />
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export default AiModerationFlagsPageClient
@@ -0,0 +1,24 @@
1
+ import React from 'react'
2
+
3
+ const moderationIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }),
7
+ React.createElement('path', { d: 'M12 8v4' }),
8
+ React.createElement('path', { d: 'M12 16h.01' }),
9
+ )
10
+
11
+ export const metadata = {
12
+ requireAuth: true,
13
+ requireFeatures: ['ai_assistant.settings.manage'],
14
+ pageTitle: 'Moderation flags',
15
+ pageTitleKey: 'ai_assistant.moderationFlags.navTitle',
16
+ pageGroup: 'Module Configs',
17
+ pageGroupKey: 'settings.sections.moduleConfigs',
18
+ pageOrder: 432,
19
+ icon: moderationIcon,
20
+ pageContext: 'settings' as const,
21
+ breadcrumb: [
22
+ { label: 'Moderation flags', labelKey: 'ai_assistant.moderationFlags.navTitle' },
23
+ ],
24
+ } as const
@@ -0,0 +1,12 @@
1
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
+ import { AiModerationFlagsPageClient } from './AiModerationFlagsPageClient'
3
+
4
+ export default async function AiModerationFlagsPage() {
5
+ return (
6
+ <Page>
7
+ <PageBody>
8
+ <AiModerationFlagsPageClient />
9
+ </PageBody>
10
+ </Page>
11
+ )
12
+ }
@@ -361,6 +361,17 @@ export class AiAgentRuntimeOverride {
361
361
  */
362
362
  @Property({ name: 'loop_active_tools_json', type: 'jsonb', nullable: true })
363
363
  loopActiveToolsJson?: unknown | null
364
+
365
+ /**
366
+ * Per-agent (or tenant-wide when `agent_id` is null) input-moderation
367
+ * override. `true` = on, `false` = off, `null` = inherit (env default →
368
+ * off). Additive nullable column; existing rows inherit. The `untrustedInput`
369
+ * agent flag still forces moderation regardless of this value.
370
+ *
371
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
372
+ */
373
+ @Property({ name: 'input_moderation', type: 'boolean', nullable: true })
374
+ inputModeration?: boolean | null
364
375
  }
365
376
 
366
377
  /**
@@ -933,3 +944,48 @@ export class AiChatMessage {
933
944
  @Property({ name: 'deleted_at', type: Date, nullable: true })
934
945
  deletedAt?: Date | null
935
946
  }
947
+
948
+ /**
949
+ * Append-only audit record of an input that the moderation gate blocked.
950
+ *
951
+ * Stores ONLY category flags + numeric scores — never the prompt content or
952
+ * any PII (Q4 data-minimization decision). Enough to identify repeat abusers
953
+ * and tune policy. No `updated_at` / `deleted_at`: rows are immutable. Two
954
+ * indexes back the audit listing (`tenant_id, created_at`) and the per-user
955
+ * abuse review (`tenant_id, user_id`); every read filters `tenant_id`.
956
+ *
957
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
958
+ */
959
+ @Entity({ tableName: 'ai_moderation_flags' })
960
+ @Index({ name: 'ai_moderation_flags_tenant_created_idx', properties: ['tenantId', 'createdAt'] })
961
+ @Index({ name: 'ai_moderation_flags_tenant_user_idx', properties: ['tenantId', 'userId'] })
962
+ export class AiModerationFlag {
963
+ [OptionalProps]?: 'id' | 'createdAt' | 'organizationId'
964
+
965
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
966
+ id!: string
967
+
968
+ @Property({ name: 'tenant_id', type: 'uuid' })
969
+ tenantId!: string
970
+
971
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
972
+ organizationId?: string | null
973
+
974
+ @Property({ name: 'agent_id', type: 'text' })
975
+ agentId!: string
976
+
977
+ @Property({ name: 'user_id', type: 'text' })
978
+ userId!: string
979
+
980
+ @Property({ name: 'provider_id', type: 'text' })
981
+ providerId!: string
982
+
983
+ @Property({ name: 'model_id', type: 'text' })
984
+ modelId!: string
985
+
986
+ @Property({ name: 'categories', type: 'jsonb' })
987
+ categories!: Record<string, { flagged: boolean; score: number }>
988
+
989
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
990
+ createdAt: Date = new Date()
991
+ }
@@ -35,6 +35,8 @@ export interface AiAgentRuntimeOverrideInput extends AiAgentRuntimeOverrideLoopI
35
35
  baseURL?: string | null
36
36
  allowedOverrideProviders?: string[] | null
37
37
  allowedOverrideModelsByProvider?: Record<string, string[]>
38
+ /** Input-moderation override: true = on, false = off, null = inherit. */
39
+ inputModeration?: boolean | null
38
40
  /**
39
41
  * Optional: the agent's declared allowedTools. When provided, loopActiveToolsJson
40
42
  * is validated to be a subset. When omitted, allowlist validation is skipped
@@ -270,6 +272,7 @@ export class AiAgentRuntimeOverrideRepository {
270
272
  if ('loopMaxTokens' in input) existing.loopMaxTokens = input.loopMaxTokens ?? null
271
273
  if ('loopStopWhenJson' in input) existing.loopStopWhenJson = input.loopStopWhenJson ?? null
272
274
  if ('loopActiveToolsJson' in input) existing.loopActiveToolsJson = input.loopActiveToolsJson ?? null
275
+ if ('inputModeration' in input) existing.inputModeration = input.inputModeration ?? null
273
276
  await tx.persist(existing).flush()
274
277
  return existing
275
278
  }
@@ -295,6 +298,7 @@ export class AiAgentRuntimeOverrideRepository {
295
298
  loopMaxTokens: input.loopMaxTokens ?? null,
296
299
  loopStopWhenJson: input.loopStopWhenJson ?? null,
297
300
  loopActiveToolsJson: input.loopActiveToolsJson ?? null,
301
+ inputModeration: input.inputModeration ?? null,
298
302
  } as unknown as AiAgentRuntimeOverride)
299
303
  await tx.persist(row).flush()
300
304
  return row
@@ -0,0 +1,79 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { AiModerationFlag } from '../entities'
3
+ import type { ModerationCategoryResult } from '../../lib/moderation'
4
+
5
+ export interface CreateModerationFlagInput {
6
+ tenantId: string
7
+ organizationId?: string | null
8
+ agentId: string
9
+ userId: string
10
+ providerId: string
11
+ modelId: string
12
+ categories: Record<string, ModerationCategoryResult>
13
+ }
14
+
15
+ export interface ListModerationFlagsFilter {
16
+ tenantId: string
17
+ organizationId?: string | null
18
+ agentId?: string
19
+ userId?: string
20
+ /** Inclusive lower bound on `created_at`. */
21
+ from?: Date
22
+ /** Inclusive upper bound on `created_at`. */
23
+ to?: Date
24
+ page: number
25
+ pageSize: number
26
+ }
27
+
28
+ export interface ModerationFlagListResult {
29
+ items: AiModerationFlag[]
30
+ total: number
31
+ }
32
+
33
+ /**
34
+ * Repository for the append-only `ai_moderation_flags` audit table. Every read
35
+ * is tenant-scoped — cross-tenant access is structurally impossible because
36
+ * `tenantId` is always part of the filter.
37
+ *
38
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
39
+ */
40
+ export class AiModerationFlagRepository {
41
+ constructor(private readonly em: EntityManager) {}
42
+
43
+ async create(input: CreateModerationFlagInput): Promise<AiModerationFlag> {
44
+ const flag = this.em.create(AiModerationFlag, {
45
+ tenantId: input.tenantId,
46
+ organizationId: input.organizationId ?? null,
47
+ agentId: input.agentId,
48
+ userId: input.userId,
49
+ providerId: input.providerId,
50
+ modelId: input.modelId,
51
+ categories: input.categories,
52
+ })
53
+ this.em.persist(flag)
54
+ await this.em.flush()
55
+ return flag
56
+ }
57
+
58
+ async list(filter: ListModerationFlagsFilter): Promise<ModerationFlagListResult> {
59
+ const where: Record<string, unknown> = { tenantId: filter.tenantId }
60
+ if (filter.organizationId !== undefined && filter.organizationId !== null) {
61
+ where.organizationId = filter.organizationId
62
+ }
63
+ if (filter.agentId) where.agentId = filter.agentId
64
+ if (filter.userId) where.userId = filter.userId
65
+ if (filter.from || filter.to) {
66
+ const createdAt: Record<string, Date> = {}
67
+ if (filter.from) createdAt.$gte = filter.from
68
+ if (filter.to) createdAt.$lte = filter.to
69
+ where.createdAt = createdAt
70
+ }
71
+
72
+ const [items, total] = await this.em.findAndCount(AiModerationFlag, where, {
73
+ orderBy: { createdAt: 'desc' },
74
+ limit: filter.pageSize,
75
+ offset: (filter.page - 1) * filter.pageSize,
76
+ })
77
+ return { items, total }
78
+ }
79
+ }
@@ -1,9 +1,13 @@
1
- import { asValue } from 'awilix'
1
+ import { asFunction, asValue } from 'awilix'
2
2
  import type { AwilixContainer } from 'awilix'
3
3
  import { toolRegistry } from './lib/tool-registry'
4
+ import { createModerationService } from './lib/moderation'
4
5
 
5
6
  export function register(container: AwilixContainer): void {
6
7
  container.register({
7
8
  mcpToolRegistry: asValue(toolRegistry),
9
+ // Input pre-moderation service (OpenAI /v1/moderations). Singleton +
10
+ // overridable by downstream apps via the module overrides DI seam.
11
+ moderationService: asFunction(() => createModerationService()).singleton(),
8
12
  })
9
13
  }