@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,122 +0,0 @@
|
|
|
1
|
-
const authMock = jest.fn()
|
|
2
|
-
const loadAclMock = jest.fn()
|
|
3
|
-
const createRequestContainerMock = jest.fn()
|
|
4
|
-
const listMock = jest.fn()
|
|
5
|
-
|
|
6
|
-
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
7
|
-
getAuthFromRequest: (...args: unknown[]) => authMock(...args),
|
|
8
|
-
}))
|
|
9
|
-
|
|
10
|
-
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
11
|
-
createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
jest.mock('../../../data/repositories/AiModerationFlagRepository', () => ({
|
|
15
|
-
AiModerationFlagRepository: jest.fn().mockImplementation(() => ({ list: listMock })),
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
import { GET } from '../route'
|
|
19
|
-
|
|
20
|
-
function buildRequest(params: Record<string, string> = {}) {
|
|
21
|
-
const url = new URL('http://localhost/api/ai_assistant/moderation-flags')
|
|
22
|
-
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
|
23
|
-
return new Request(url.toString(), { method: 'GET' })
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
27
|
-
return {
|
|
28
|
-
id: 'flag-1',
|
|
29
|
-
tenantId: 'tenant-1',
|
|
30
|
-
organizationId: null,
|
|
31
|
-
agentId: 'catalog.assistant',
|
|
32
|
-
userId: 'user-9',
|
|
33
|
-
providerId: 'openai',
|
|
34
|
-
modelId: 'gpt-5-mini',
|
|
35
|
-
categories: { hate: { flagged: true, score: 0.97 } },
|
|
36
|
-
createdAt: new Date('2026-06-10T12:00:00Z'),
|
|
37
|
-
...overrides,
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('GET /api/ai_assistant/moderation-flags', () => {
|
|
42
|
-
let consoleErrorSpy: jest.SpyInstance
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
jest.clearAllMocks()
|
|
46
|
-
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
47
|
-
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
48
|
-
loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
|
|
49
|
-
createRequestContainerMock.mockResolvedValue({
|
|
50
|
-
resolve: (name: string) => {
|
|
51
|
-
if (name === 'rbacService') {
|
|
52
|
-
return {
|
|
53
|
-
loadAcl: loadAclMock,
|
|
54
|
-
hasAllFeatures: (req: string[], have: string[]) => req.every((r) => have.includes(r)),
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (name === 'em') return {}
|
|
58
|
-
throw new Error(`Unknown token: ${name}`)
|
|
59
|
-
},
|
|
60
|
-
})
|
|
61
|
-
listMock.mockResolvedValue({ items: [], total: 0 })
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
afterEach(() => {
|
|
65
|
-
consoleErrorSpy.mockRestore()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('returns 401 when unauthenticated', async () => {
|
|
69
|
-
authMock.mockResolvedValue(null)
|
|
70
|
-
const res = await GET(buildRequest() as never)
|
|
71
|
-
expect(res.status).toBe(401)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('returns 403 when the caller lacks ai_assistant.settings.manage', async () => {
|
|
75
|
-
loadAclMock.mockResolvedValue({ features: ['ai_assistant.view'], isSuperAdmin: false })
|
|
76
|
-
const res = await GET(buildRequest() as never)
|
|
77
|
-
expect(res.status).toBe(403)
|
|
78
|
-
expect((await res.json()).code).toBe('forbidden')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('rejects pageSize above the 100 cap with a validation error', async () => {
|
|
82
|
-
const res = await GET(buildRequest({ pageSize: '101' }) as never)
|
|
83
|
-
expect(res.status).toBe(400)
|
|
84
|
-
expect((await res.json()).code).toBe('validation_error')
|
|
85
|
-
expect(listMock).not.toHaveBeenCalled()
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('lists tenant-scoped rows and echoes pagination', async () => {
|
|
89
|
-
listMock.mockResolvedValue({ items: [makeRow()], total: 1 })
|
|
90
|
-
const res = await GET(buildRequest({ page: '2', pageSize: '25' }) as never)
|
|
91
|
-
expect(res.status).toBe(200)
|
|
92
|
-
const json = await res.json()
|
|
93
|
-
expect(json.total).toBe(1)
|
|
94
|
-
expect(json.page).toBe(2)
|
|
95
|
-
expect(json.pageSize).toBe(25)
|
|
96
|
-
expect(json.items[0]).toMatchObject({ id: 'flag-1', userId: 'user-9', categories: { hate: { flagged: true, score: 0.97 } } })
|
|
97
|
-
// Tenant-scoped only — the listing does NOT narrow by organization_id.
|
|
98
|
-
expect(listMock).toHaveBeenCalledWith(
|
|
99
|
-
expect.objectContaining({ tenantId: 'tenant-1', page: 2, pageSize: 25 }),
|
|
100
|
-
)
|
|
101
|
-
expect(listMock.mock.calls[0][0].organizationId).toBeUndefined()
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('passes agentId/userId/date filters through to the repository', async () => {
|
|
105
|
-
await GET(buildRequest({ agentId: 'catalog.assistant', userId: 'user-9', from: '2026-06-01', to: '2026-06-10' }) as never)
|
|
106
|
-
const call = listMock.mock.calls[0][0]
|
|
107
|
-
expect(call.agentId).toBe('catalog.assistant')
|
|
108
|
-
expect(call.userId).toBe('user-9')
|
|
109
|
-
expect(call.from).toBeInstanceOf(Date)
|
|
110
|
-
expect(call.to).toBeInstanceOf(Date)
|
|
111
|
-
// `to` is extended to end-of-day so same-day rows are included.
|
|
112
|
-
expect((call.to as Date).toISOString()).toBe('2026-06-10T23:59:59.999Z')
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('returns an empty page (no repo call) when the caller has no tenant scope', async () => {
|
|
116
|
-
authMock.mockResolvedValue({ sub: 'user-1', tenantId: null, orgId: null })
|
|
117
|
-
const res = await GET(buildRequest() as never)
|
|
118
|
-
expect(res.status).toBe(200)
|
|
119
|
-
expect((await res.json()).items).toEqual([])
|
|
120
|
-
expect(listMock).not.toHaveBeenCalled()
|
|
121
|
-
})
|
|
122
|
-
})
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
-
import { z } from 'zod'
|
|
3
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
-
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
-
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
|
-
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
|
-
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
-
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
9
|
-
import { AiModerationFlagRepository } from '../../data/repositories/AiModerationFlagRepository'
|
|
10
|
-
import { hasRequiredFeatures } from '../../lib/auth'
|
|
11
|
-
|
|
12
|
-
const logger = createLogger('ai_assistant')
|
|
13
|
-
|
|
14
|
-
const REQUIRED_FEATURE = 'ai_assistant.settings.manage'
|
|
15
|
-
const MAX_PAGE_SIZE = 100
|
|
16
|
-
|
|
17
|
-
const querySchema = z.object({
|
|
18
|
-
page: z.coerce.number().int().min(1).default(1),
|
|
19
|
-
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(50),
|
|
20
|
-
agentId: z.string().min(1).max(256).optional(),
|
|
21
|
-
userId: z.string().min(1).max(256).optional(),
|
|
22
|
-
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be a date in YYYY-MM-DD format').optional(),
|
|
23
|
-
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be a date in YYYY-MM-DD format').optional(),
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
export const openApi: OpenApiRouteDoc = {
|
|
27
|
-
tag: 'AI Assistant',
|
|
28
|
-
summary: 'Input moderation flags audit log',
|
|
29
|
-
methods: {
|
|
30
|
-
GET: {
|
|
31
|
-
operationId: 'aiAssistantModerationFlags',
|
|
32
|
-
summary: 'List input-moderation audit flags for the current tenant.',
|
|
33
|
-
description:
|
|
34
|
-
'Returns the append-only `ai_moderation_flags` audit rows (category flags + scores only — never ' +
|
|
35
|
-
'prompt content) for the caller\'s tenant. Always tenant-scoped; cross-tenant access is impossible. ' +
|
|
36
|
-
'Optionally filtered by `agentId`, `userId`, and a `from`/`to` date window. Paginated (`pageSize` ≤ 100). ' +
|
|
37
|
-
'Requires `ai_assistant.settings.manage`.',
|
|
38
|
-
query: querySchema,
|
|
39
|
-
responses: [
|
|
40
|
-
{
|
|
41
|
-
status: 200,
|
|
42
|
-
description: 'Paginated moderation-flag rows: `{ items, total, page, pageSize }`.',
|
|
43
|
-
mediaType: 'application/json',
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
errors: [
|
|
47
|
-
{ status: 400, description: 'Invalid query parameters.' },
|
|
48
|
-
{ status: 401, description: 'Unauthenticated caller.' },
|
|
49
|
-
{ status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
|
|
50
|
-
{ status: 500, description: 'Internal failure.' },
|
|
51
|
-
],
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const metadata = {
|
|
57
|
-
path: '/ai_assistant/moderation-flags',
|
|
58
|
-
GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function jsonError(status: number, message: string, code: string, extra?: Record<string, unknown>): NextResponse {
|
|
62
|
-
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function GET(req: NextRequest): Promise<Response> {
|
|
66
|
-
const auth = await getAuthFromRequest(req)
|
|
67
|
-
if (!auth) {
|
|
68
|
-
return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const { searchParams } = new URL(req.url)
|
|
72
|
-
const queryResult = querySchema.safeParse({
|
|
73
|
-
page: searchParams.get('page') ?? undefined,
|
|
74
|
-
pageSize: searchParams.get('pageSize') ?? undefined,
|
|
75
|
-
agentId: searchParams.get('agentId') ?? undefined,
|
|
76
|
-
userId: searchParams.get('userId') ?? undefined,
|
|
77
|
-
from: searchParams.get('from') ?? undefined,
|
|
78
|
-
to: searchParams.get('to') ?? undefined,
|
|
79
|
-
})
|
|
80
|
-
if (!queryResult.success) {
|
|
81
|
-
return jsonError(400, 'Invalid query parameters.', 'validation_error', {
|
|
82
|
-
issues: queryResult.error.issues,
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const { page, pageSize, agentId, userId, from, to } = queryResult.data
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const container = await createRequestContainer()
|
|
90
|
-
const rbacService = container.resolve<RbacService>('rbacService')
|
|
91
|
-
const acl = await rbacService.loadAcl(auth.sub, {
|
|
92
|
-
tenantId: auth.tenantId,
|
|
93
|
-
organizationId: auth.orgId,
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
97
|
-
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!auth.tenantId) {
|
|
101
|
-
return NextResponse.json({ items: [], total: 0, page, pageSize })
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const em = container.resolve<EntityManager>('em')
|
|
105
|
-
const repo = new AiModerationFlagRepository(em)
|
|
106
|
-
const { items, total } = await repo.list({
|
|
107
|
-
tenantId: auth.tenantId,
|
|
108
|
-
// Tenant-scoped abuse audit: do NOT narrow by organization_id. Tenant is
|
|
109
|
-
// the hard isolation boundary; portal-surface flags often carry no org,
|
|
110
|
-
// and a tenant admin reviewing abuse needs every flag in the tenant.
|
|
111
|
-
agentId,
|
|
112
|
-
userId,
|
|
113
|
-
// `to` is an inclusive day; extend to end-of-day so same-day rows match.
|
|
114
|
-
from: from ? new Date(`${from}T00:00:00.000Z`) : undefined,
|
|
115
|
-
to: to ? new Date(`${to}T23:59:59.999Z`) : undefined,
|
|
116
|
-
page,
|
|
117
|
-
pageSize,
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
const serialized = items.map((row) => ({
|
|
121
|
-
id: row.id,
|
|
122
|
-
tenantId: row.tenantId,
|
|
123
|
-
organizationId: row.organizationId ?? null,
|
|
124
|
-
agentId: row.agentId,
|
|
125
|
-
userId: row.userId,
|
|
126
|
-
providerId: row.providerId,
|
|
127
|
-
modelId: row.modelId,
|
|
128
|
-
categories: row.categories,
|
|
129
|
-
createdAt: row.createdAt.toISOString(),
|
|
130
|
-
}))
|
|
131
|
-
|
|
132
|
-
return NextResponse.json({ items: serialized, total, page, pageSize })
|
|
133
|
-
} catch (error) {
|
|
134
|
-
logger.error('Moderation flags GET failed', { err: error })
|
|
135
|
-
return jsonError(500, 'Failed to fetch moderation flags.', 'internal_error')
|
|
136
|
-
}
|
|
137
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
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
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
}
|