@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.
Files changed (120) 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 +121 -15
  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/allowlist/AiTenantAllowlistPageClient.js +2 -2
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/entities.js +42 -0
  30. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  31. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  32. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  33. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  34. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  35. package/dist/modules/ai_assistant/di.js +6 -2
  36. package/dist/modules/ai_assistant/di.js.map +2 -2
  37. package/dist/modules/ai_assistant/events.js +6 -0
  38. package/dist/modules/ai_assistant/events.js.map +2 -2
  39. package/dist/modules/ai_assistant/i18n/de.json +42 -0
  40. package/dist/modules/ai_assistant/i18n/en.json +42 -0
  41. package/dist/modules/ai_assistant/i18n/es.json +42 -0
  42. package/dist/modules/ai_assistant/i18n/ko.json +527 -0
  43. package/dist/modules/ai_assistant/i18n/pl.json +42 -0
  44. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  45. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  47. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  48. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  49. package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
  50. package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
  51. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  52. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  54. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  56. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  58. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  59. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  60. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  61. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  62. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  63. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  64. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  65. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  66. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  67. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  68. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  69. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  70. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  71. package/generated/entities.ids.generated.ts +2 -1
  72. package/generated/entity-fields-registry.ts +13 -1
  73. package/package.json +13 -12
  74. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  75. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  76. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  77. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  78. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  79. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  80. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  81. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  82. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  83. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +161 -15
  84. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
  85. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  86. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  87. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  88. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
  89. package/src/modules/ai_assistant/data/entities.ts +56 -0
  90. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  91. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  92. package/src/modules/ai_assistant/di.ts +5 -1
  93. package/src/modules/ai_assistant/events.ts +21 -0
  94. package/src/modules/ai_assistant/i18n/de.json +42 -0
  95. package/src/modules/ai_assistant/i18n/en.json +42 -0
  96. package/src/modules/ai_assistant/i18n/es.json +42 -0
  97. package/src/modules/ai_assistant/i18n/ko.json +527 -0
  98. package/src/modules/ai_assistant/i18n/pl.json +42 -0
  99. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  100. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  101. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  102. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  103. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  104. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  105. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  106. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  107. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  108. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  109. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  110. package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
  111. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  112. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  113. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  114. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  115. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  116. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  117. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  118. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  119. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  120. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/tool-search.ts"],
4
- "sourcesContent": ["import type { SearchService } from '@open-mercato/search/service'\nimport type { SearchStrategyId, IndexableRecord } from '@open-mercato/search/types'\nimport type { McpToolRegistry, McpToolDefinition } from './types'\nimport {\n TOOL_ENTITY_ID,\n GLOBAL_TENANT_ID,\n TOOL_SEARCH_CONFIG,\n toolToIndexableRecord,\n computeToolsChecksum,\n} from './tool-index-config'\n\n/**\n * Result from tool search.\n */\nexport type ToolSearchResult = {\n /** Tool name (recordId) */\n toolName: string\n /** Relevance score */\n score: number\n /** Module that registered the tool */\n moduleId?: string\n /** Features required to use this tool */\n requiredFeatures?: string[]\n}\n\n/**\n * Status of available search strategies.\n */\nexport type StrategyStatus = {\n fulltext: boolean\n vector: boolean\n tokens: boolean\n /** Ordered list of available strategies */\n available: SearchStrategyId[]\n /** Strategies that will be used by default */\n default: SearchStrategyId[]\n}\n\n/**\n * Result from tool indexing operation.\n */\nexport type IndexingResult = {\n indexed: number\n skipped: number\n strategies: SearchStrategyId[]\n checksum: string\n}\n\n/**\n * Options for searching tools.\n */\nexport type ToolSearchOptions = {\n /** Maximum results to return (default: 12) */\n limit?: number\n /** Override strategies to use */\n strategies?: SearchStrategyId[]\n /** User's features for ACL filtering */\n userFeatures?: string[]\n /** Is user a super admin (bypasses ACL) */\n isSuperAdmin?: boolean\n /** Minimum score threshold (default: 0.2) */\n minScore?: number\n}\n\n/**\n * Service for searching and indexing MCP tools using hybrid search strategies.\n *\n * Uses the existing search module infrastructure to provide:\n * - Fulltext search (Meilisearch) when configured\n * - Vector/semantic search (PgVector) when configured\n * - Token-based search (PostgreSQL hash) as fallback\n *\n * Results are merged using Reciprocal Rank Fusion (RRF) with weights:\n * - fulltext: 1.2\n * - vector: 1.0\n * - tokens: 0.8\n */\nexport class ToolSearchService {\n private lastIndexChecksum: string | null = null\n\n constructor(\n private readonly searchService: SearchService,\n private readonly toolRegistry: McpToolRegistry\n ) {}\n\n /**\n * Get status of available search strategies.\n * This helps the AI understand which integrations are available.\n */\n async getStrategyStatus(): Promise<StrategyStatus> {\n const [fulltext, vector, tokens] = await Promise.all([\n this.searchService.isStrategyAvailable('fulltext'),\n this.searchService.isStrategyAvailable('vector'),\n this.searchService.isStrategyAvailable('tokens'),\n ])\n\n const available: SearchStrategyId[] = []\n if (fulltext) available.push('fulltext')\n if (vector) available.push('vector')\n if (tokens) available.push('tokens')\n\n const defaultStrategies = this.searchService.getDefaultStrategies()\n\n return {\n fulltext,\n vector,\n tokens,\n available,\n default: defaultStrategies.filter((s) => available.includes(s)),\n }\n }\n\n /**\n * Search for relevant tools using hybrid strategies.\n *\n * @param query - User's search query or message\n * @param options - Search options\n * @returns Array of matching tools sorted by relevance\n */\n async searchTools(\n query: string,\n options: ToolSearchOptions = {}\n ): Promise<ToolSearchResult[]> {\n const {\n limit = TOOL_SEARCH_CONFIG.defaultLimit,\n strategies,\n userFeatures = [],\n isSuperAdmin = false,\n minScore = TOOL_SEARCH_CONFIG.minScore,\n } = options\n\n // Use hybrid search with all available strategies\n const searchResults = await this.searchService.search(query, {\n tenantId: GLOBAL_TENANT_ID,\n organizationId: null,\n entityTypes: [TOOL_ENTITY_ID],\n strategies,\n limit: limit * 2, // Get extra results to account for ACL filtering\n })\n\n // Map to tool results and filter by ACL\n const toolResults: ToolSearchResult[] = []\n\n for (const result of searchResults) {\n // Skip results below minimum score\n if (result.score < minScore) continue\n\n const metadata = result.metadata as Record<string, unknown> | undefined\n const requiredFeatures = (metadata?.requiredFeatures as string[]) ?? []\n const moduleId = metadata?.moduleId as string | undefined\n\n // Filter by user's feature access\n if (!this.hasFeatureAccess(requiredFeatures, userFeatures, isSuperAdmin)) {\n continue\n }\n\n toolResults.push({\n toolName: result.recordId,\n score: result.score,\n moduleId,\n requiredFeatures,\n })\n\n // Stop when we have enough results\n if (toolResults.length >= limit) break\n }\n\n return toolResults\n }\n\n /**\n * Index all tools from the registry using available strategies.\n * Uses checksum-based change detection to avoid unnecessary re-indexing.\n *\n * @param force - Force re-indexing even if checksum hasn't changed\n * @returns Indexing result with statistics\n */\n async indexTools(force = false): Promise<IndexingResult> {\n const tools = Array.from(this.toolRegistry.getTools().values())\n const modules = this.getToolModules()\n\n // Compute checksum to detect changes\n const checksum = computeToolsChecksum(\n tools.map((t) => ({ name: t.name, description: t.description }))\n )\n\n // Skip if checksum matches and not forced\n if (!force && this.lastIndexChecksum === checksum) {\n const status = await this.getStrategyStatus()\n return {\n indexed: 0,\n skipped: tools.length,\n strategies: status.available,\n checksum,\n }\n }\n\n // Build indexable records for all tools\n const records: IndexableRecord[] = []\n for (const tool of tools) {\n const moduleId = modules.get(tool.name)\n records.push(toolToIndexableRecord(tool, moduleId))\n }\n\n // Bulk index using available strategies\n if (records.length > 0) {\n try {\n await this.searchService.bulkIndex(records)\n } catch (error) {\n throw new Error(\n `Failed to index ${records.length} tools: ${\n error instanceof Error ? error.message : error\n }`\n )\n }\n }\n\n // Update checksum\n this.lastIndexChecksum = checksum\n\n const status = await this.getStrategyStatus()\n return {\n indexed: records.length,\n skipped: 0,\n strategies: status.available,\n checksum,\n }\n }\n\n /**\n * Purge all tool records from the search index.\n * Useful for cleanup or complete re-indexing.\n */\n async purgeIndex(): Promise<void> {\n await this.searchService.purge(TOOL_ENTITY_ID, GLOBAL_TENANT_ID)\n this.lastIndexChecksum = null\n }\n\n /**\n * Get tools by module ID from the registry.\n */\n getToolsByModule(moduleId: string): string[] {\n return this.toolRegistry.listToolsByModule(moduleId)\n }\n\n /**\n * Build a map of tool name to module ID.\n */\n private getToolModules(): Map<string, string> {\n const modules = new Map<string, string>()\n\n // Common module prefixes that indicate tool ownership\n const modulePatterns = [\n { pattern: /^customers_/, moduleId: 'customers' },\n { pattern: /^sales_/, moduleId: 'sales' },\n { pattern: /^catalog_/, moduleId: 'catalog' },\n { pattern: /^staff_/, moduleId: 'staff' },\n { pattern: /^resources_/, moduleId: 'resources' },\n { pattern: /^planner/, moduleId: 'planner' },\n { pattern: /^search_/, moduleId: 'search' },\n { pattern: /^context_/, moduleId: 'context' },\n { pattern: /^auth_/, moduleId: 'auth' },\n { pattern: /^directory_/, moduleId: 'directory' },\n { pattern: /^dashboards_/, moduleId: 'dashboards' },\n { pattern: /^entities_/, moduleId: 'entities' },\n { pattern: /^dictionaries_/, moduleId: 'dictionaries' },\n { pattern: /^workflows_/, moduleId: 'workflows' },\n { pattern: /^business_rules_/, moduleId: 'business_rules' },\n { pattern: /^audit_logs_/, moduleId: 'audit_logs' },\n { pattern: /^attachments_/, moduleId: 'attachments' },\n { pattern: /^feature_toggles_/, moduleId: 'feature_toggles' },\n { pattern: /^currencies_/, moduleId: 'currencies' },\n { pattern: /^example_/, moduleId: 'example' },\n { pattern: /^cli_/, moduleId: 'cli' },\n ]\n\n for (const toolName of this.toolRegistry.listToolNames()) {\n for (const { pattern, moduleId } of modulePatterns) {\n if (pattern.test(toolName)) {\n modules.set(toolName, moduleId)\n break\n }\n }\n }\n\n return modules\n }\n\n /**\n * Check if user has required features to access a tool.\n */\n private hasFeatureAccess(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean\n ): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n return requiredFeatures.every((required) => {\n // Direct match\n if (userFeatures.includes(required)) return true\n // Wildcard match\n if (userFeatures.includes('*')) return true\n\n // Prefix wildcard match (e.g., 'customers.*' matches 'customers.people.view')\n return userFeatures.some((feature) => {\n if (feature.endsWith('.*')) {\n const prefix = feature.slice(0, -2)\n return required.startsWith(prefix + '.')\n }\n return false\n })\n })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { SearchService } from '@open-mercato/search/service'\nimport type { SearchStrategyId, IndexableRecord } from '@open-mercato/search/types'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport type { McpToolRegistry, McpToolDefinition } from './types'\nimport {\n TOOL_ENTITY_ID,\n GLOBAL_TENANT_ID,\n TOOL_SEARCH_CONFIG,\n toolToIndexableRecord,\n computeToolsChecksum,\n} from './tool-index-config'\n\n/**\n * Result from tool search.\n */\nexport type ToolSearchResult = {\n /** Tool name (recordId) */\n toolName: string\n /** Relevance score */\n score: number\n /** Module that registered the tool */\n moduleId?: string\n /** Features required to use this tool */\n requiredFeatures?: string[]\n}\n\n/**\n * Status of available search strategies.\n */\nexport type StrategyStatus = {\n fulltext: boolean\n vector: boolean\n tokens: boolean\n /** Ordered list of available strategies */\n available: SearchStrategyId[]\n /** Strategies that will be used by default */\n default: SearchStrategyId[]\n}\n\n/**\n * Result from tool indexing operation.\n */\nexport type IndexingResult = {\n indexed: number\n skipped: number\n strategies: SearchStrategyId[]\n checksum: string\n}\n\n/**\n * Options for searching tools.\n */\nexport type ToolSearchOptions = {\n /** Maximum results to return (default: 12) */\n limit?: number\n /** Override strategies to use */\n strategies?: SearchStrategyId[]\n /** User's features for ACL filtering */\n userFeatures?: string[]\n /** Is user a super admin (bypasses ACL) */\n isSuperAdmin?: boolean\n /** Minimum score threshold (default: 0.2) */\n minScore?: number\n}\n\n/**\n * Service for searching and indexing MCP tools using hybrid search strategies.\n *\n * Uses the existing search module infrastructure to provide:\n * - Fulltext search (Meilisearch) when configured\n * - Vector/semantic search (PgVector) when configured\n * - Token-based search (PostgreSQL hash) as fallback\n *\n * Results are merged using Reciprocal Rank Fusion (RRF) with weights:\n * - fulltext: 1.2\n * - vector: 1.0\n * - tokens: 0.8\n */\nexport class ToolSearchService {\n private lastIndexChecksum: string | null = null\n\n constructor(\n private readonly searchService: SearchService,\n private readonly toolRegistry: McpToolRegistry\n ) {}\n\n /**\n * Get status of available search strategies.\n * This helps the AI understand which integrations are available.\n */\n async getStrategyStatus(): Promise<StrategyStatus> {\n const [fulltext, vector, tokens] = await Promise.all([\n this.searchService.isStrategyAvailable('fulltext'),\n this.searchService.isStrategyAvailable('vector'),\n this.searchService.isStrategyAvailable('tokens'),\n ])\n\n const available: SearchStrategyId[] = []\n if (fulltext) available.push('fulltext')\n if (vector) available.push('vector')\n if (tokens) available.push('tokens')\n\n const defaultStrategies = this.searchService.getDefaultStrategies()\n\n return {\n fulltext,\n vector,\n tokens,\n available,\n default: defaultStrategies.filter((s) => available.includes(s)),\n }\n }\n\n /**\n * Search for relevant tools using hybrid strategies.\n *\n * @param query - User's search query or message\n * @param options - Search options\n * @returns Array of matching tools sorted by relevance\n */\n async searchTools(\n query: string,\n options: ToolSearchOptions = {}\n ): Promise<ToolSearchResult[]> {\n const {\n limit = TOOL_SEARCH_CONFIG.defaultLimit,\n strategies,\n userFeatures = [],\n isSuperAdmin = false,\n minScore = TOOL_SEARCH_CONFIG.minScore,\n } = options\n\n // Use hybrid search with all available strategies\n const searchResults = await this.searchService.search(query, {\n tenantId: GLOBAL_TENANT_ID,\n organizationId: null,\n entityTypes: [TOOL_ENTITY_ID],\n strategies,\n limit: limit * 2, // Get extra results to account for ACL filtering\n })\n\n // Map to tool results and filter by ACL\n const toolResults: ToolSearchResult[] = []\n\n for (const result of searchResults) {\n // Skip results below minimum score\n if (result.score < minScore) continue\n\n const metadata = result.metadata as Record<string, unknown> | undefined\n const requiredFeatures = (metadata?.requiredFeatures as string[]) ?? []\n const moduleId = metadata?.moduleId as string | undefined\n\n // Filter by user's feature access\n if (!this.hasFeatureAccess(requiredFeatures, userFeatures, isSuperAdmin)) {\n continue\n }\n\n toolResults.push({\n toolName: result.recordId,\n score: result.score,\n moduleId,\n requiredFeatures,\n })\n\n // Stop when we have enough results\n if (toolResults.length >= limit) break\n }\n\n return toolResults\n }\n\n /**\n * Index all tools from the registry using available strategies.\n * Uses checksum-based change detection to avoid unnecessary re-indexing.\n *\n * @param force - Force re-indexing even if checksum hasn't changed\n * @returns Indexing result with statistics\n */\n async indexTools(force = false): Promise<IndexingResult> {\n const tools = Array.from(this.toolRegistry.getTools().values())\n const modules = this.getToolModules()\n\n // Compute checksum to detect changes\n const checksum = computeToolsChecksum(\n tools.map((t) => ({ name: t.name, description: t.description }))\n )\n\n // Skip if checksum matches and not forced\n if (!force && this.lastIndexChecksum === checksum) {\n const status = await this.getStrategyStatus()\n return {\n indexed: 0,\n skipped: tools.length,\n strategies: status.available,\n checksum,\n }\n }\n\n // Build indexable records for all tools\n const records: IndexableRecord[] = []\n for (const tool of tools) {\n const moduleId = modules.get(tool.name)\n records.push(toolToIndexableRecord(tool, moduleId))\n }\n\n // Bulk index using available strategies\n if (records.length > 0) {\n try {\n await this.searchService.bulkIndex(records)\n } catch (error) {\n throw new Error(\n `Failed to index ${records.length} tools: ${\n error instanceof Error ? error.message : error\n }`\n )\n }\n }\n\n // Update checksum\n this.lastIndexChecksum = checksum\n\n const status = await this.getStrategyStatus()\n return {\n indexed: records.length,\n skipped: 0,\n strategies: status.available,\n checksum,\n }\n }\n\n /**\n * Purge all tool records from the search index.\n * Useful for cleanup or complete re-indexing.\n */\n async purgeIndex(): Promise<void> {\n await this.searchService.purge(TOOL_ENTITY_ID, GLOBAL_TENANT_ID)\n this.lastIndexChecksum = null\n }\n\n /**\n * Get tools by module ID from the registry.\n */\n getToolsByModule(moduleId: string): string[] {\n return this.toolRegistry.listToolsByModule(moduleId)\n }\n\n /**\n * Build a map of tool name to module ID.\n */\n private getToolModules(): Map<string, string> {\n const modules = new Map<string, string>()\n\n // Common module prefixes that indicate tool ownership\n const modulePatterns = [\n { pattern: /^customers_/, moduleId: 'customers' },\n { pattern: /^sales_/, moduleId: 'sales' },\n { pattern: /^catalog_/, moduleId: 'catalog' },\n { pattern: /^staff_/, moduleId: 'staff' },\n { pattern: /^resources_/, moduleId: 'resources' },\n { pattern: /^planner/, moduleId: 'planner' },\n { pattern: /^search_/, moduleId: 'search' },\n { pattern: /^context_/, moduleId: 'context' },\n { pattern: /^auth_/, moduleId: 'auth' },\n { pattern: /^directory_/, moduleId: 'directory' },\n { pattern: /^dashboards_/, moduleId: 'dashboards' },\n { pattern: /^entities_/, moduleId: 'entities' },\n { pattern: /^dictionaries_/, moduleId: 'dictionaries' },\n { pattern: /^workflows_/, moduleId: 'workflows' },\n { pattern: /^business_rules_/, moduleId: 'business_rules' },\n { pattern: /^audit_logs_/, moduleId: 'audit_logs' },\n { pattern: /^attachments_/, moduleId: 'attachments' },\n { pattern: /^feature_toggles_/, moduleId: 'feature_toggles' },\n { pattern: /^currencies_/, moduleId: 'currencies' },\n { pattern: /^example_/, moduleId: 'example' },\n { pattern: /^cli_/, moduleId: 'cli' },\n ]\n\n for (const toolName of this.toolRegistry.listToolNames()) {\n for (const { pattern, moduleId } of modulePatterns) {\n if (pattern.test(toolName)) {\n modules.set(toolName, moduleId)\n break\n }\n }\n }\n\n return modules\n }\n\n /**\n * Check if user has required features to access a tool.\n */\n private hasFeatureAccess(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean\n ): boolean {\n return authorizeFeatures(requiredFeatures ?? [], {\n grantedFeatures: userFeatures,\n unrestricted: isSuperAdmin,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["status"]
7
7
  }
@@ -0,0 +1,19 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260610134045_ai_assistant extends Migration {
3
+ up() {
4
+ this.addSql(`create table "ai_moderation_flags" ("id" uuid not null default gen_random_uuid(), "tenant_id" uuid not null, "organization_id" uuid null, "agent_id" text not null, "user_id" text not null, "provider_id" text not null, "model_id" text not null, "categories" jsonb not null, "created_at" timestamptz not null, primary key ("id"));`);
5
+ this.addSql(`create index "ai_moderation_flags_tenant_user_idx" on "ai_moderation_flags" ("tenant_id", "user_id");`);
6
+ this.addSql(`create index "ai_moderation_flags_tenant_created_idx" on "ai_moderation_flags" ("tenant_id", "created_at");`);
7
+ this.addSql(`alter table "ai_agent_runtime_overrides" add "input_moderation" boolean null;`);
8
+ }
9
+ down() {
10
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "input_moderation";`);
11
+ this.addSql(`drop index "ai_moderation_flags_tenant_created_idx";`);
12
+ this.addSql(`drop index "ai_moderation_flags_tenant_user_idx";`);
13
+ this.addSql(`drop table if exists "ai_moderation_flags" cascade;`);
14
+ }
15
+ }
16
+ export {
17
+ Migration20260610134045_ai_assistant
18
+ };
19
+ //# sourceMappingURL=Migration20260610134045_ai_assistant.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260610134045_ai_assistant extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`create table \"ai_moderation_flags\" (\"id\" uuid not null default gen_random_uuid(), \"tenant_id\" uuid not null, \"organization_id\" uuid null, \"agent_id\" text not null, \"user_id\" text not null, \"provider_id\" text not null, \"model_id\" text not null, \"categories\" jsonb not null, \"created_at\" timestamptz not null, primary key (\"id\"));`);\n this.addSql(`create index \"ai_moderation_flags_tenant_user_idx\" on \"ai_moderation_flags\" (\"tenant_id\", \"user_id\");`);\n this.addSql(`create index \"ai_moderation_flags_tenant_created_idx\" on \"ai_moderation_flags\" (\"tenant_id\", \"created_at\");`);\n\n this.addSql(`alter table \"ai_agent_runtime_overrides\" add \"input_moderation\" boolean null;`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`alter table \"ai_agent_runtime_overrides\" drop column \"input_moderation\";`);\n\n this.addSql(`drop index \"ai_moderation_flags_tenant_created_idx\";`);\n this.addSql(`drop index \"ai_moderation_flags_tenant_user_idx\";`);\n this.addSql(`drop table if exists \"ai_moderation_flags\" cascade;`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,6CAA6C,UAAU;AAAA,EAEzD,KAA2B;AAClC,SAAK,OAAO,0UAA0U;AACtV,SAAK,OAAO,uGAAuG;AACnH,SAAK,OAAO,6GAA6G;AAEzH,SAAK,OAAO,+EAA+E;AAAA,EAC7F;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,0EAA0E;AAEtF,SAAK,OAAO,sDAAsD;AAClE,SAAK,OAAO,mDAAmD;AAC/D,SAAK,OAAO,qDAAqD;AAAA,EACnE;AAEF;",
6
+ "names": []
7
+ }
@@ -18,3 +18,4 @@ export const loop_max_wall_clock_ms = "loop_max_wall_clock_ms";
18
18
  export const loop_max_tokens = "loop_max_tokens";
19
19
  export const loop_stop_when_json = "loop_stop_when_json";
20
20
  export const loop_active_tools_json = "loop_active_tools_json";
21
+ export const input_moderation = "input_moderation";
@@ -0,0 +1,9 @@
1
+ export const id = "id";
2
+ export const tenant_id = "tenant_id";
3
+ export const organization_id = "organization_id";
4
+ export const agent_id = "agent_id";
5
+ export const user_id = "user_id";
6
+ export const provider_id = "provider_id";
7
+ export const model_id = "model_id";
8
+ export const categories = "categories";
9
+ export const created_at = "created_at";
@@ -12,7 +12,8 @@ export const M = {
12
12
  "ai_agent_mutation_policy_override": "ai_assistant:ai_agent_mutation_policy_override",
13
13
  "ai_chat_conversation": "ai_assistant:ai_chat_conversation",
14
14
  "ai_chat_conversation_participant": "ai_assistant:ai_chat_conversation_participant",
15
- "ai_chat_message": "ai_assistant:ai_chat_message"
15
+ "ai_chat_message": "ai_assistant:ai_chat_message",
16
+ "ai_moderation_flag": "ai_assistant:ai_moderation_flag"
16
17
  }
17
18
  } as const;
18
19
 
@@ -44,7 +44,8 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
44
44
  "loop_max_wall_clock_ms": "loop_max_wall_clock_ms",
45
45
  "loop_max_tokens": "loop_max_tokens",
46
46
  "loop_stop_when_json": "loop_stop_when_json",
47
- "loop_active_tools_json": "loop_active_tools_json"
47
+ "loop_active_tools_json": "loop_active_tools_json",
48
+ "input_moderation": "input_moderation"
48
49
  },
49
50
  "ai_chat_conversation": {
50
51
  "id": "id",
@@ -93,6 +94,17 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
93
94
  "updated_at": "updated_at",
94
95
  "deleted_at": "deleted_at"
95
96
  },
97
+ "ai_moderation_flag": {
98
+ "id": "id",
99
+ "tenant_id": "tenant_id",
100
+ "organization_id": "organization_id",
101
+ "agent_id": "agent_id",
102
+ "user_id": "user_id",
103
+ "provider_id": "provider_id",
104
+ "model_id": "model_id",
105
+ "categories": "categories",
106
+ "created_at": "created_at"
107
+ },
96
108
  "ai_pending_action": {
97
109
  "id": "id",
98
110
  "tenant_id": "tenant_id",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.6.7",
3
+ "version": "0.6.8-develop.6874.1.982d6097d8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -86,33 +86,33 @@
86
86
  }
87
87
  },
88
88
  "dependencies": {
89
- "@ai-sdk/anthropic": "^4.0.1",
89
+ "@ai-sdk/anthropic": "^4.0.23",
90
90
  "@ai-sdk/google": "^4.0.2",
91
91
  "@ai-sdk/openai": "^4.0.2",
92
- "@modelcontextprotocol/sdk": "^1.29.0",
92
+ "@modelcontextprotocol/sdk": "^1.30.0",
93
93
  "ai": "^7.0.4",
94
94
  "cmdk": "^1.0.0",
95
- "framer-motion": "^12.42.2",
95
+ "framer-motion": "^12.43.0",
96
96
  "isolated-vm": "^7.0.0",
97
97
  "react-json-view-lite": "^2.5.0",
98
98
  "react-markdown": "^10.1.0",
99
99
  "zod-to-json-schema": "^3.25.2"
100
100
  },
101
101
  "peerDependencies": {
102
- "@open-mercato/shared": "0.6.7",
103
- "@open-mercato/ui": "0.6.7",
102
+ "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
103
+ "@open-mercato/ui": "0.6.8-develop.6874.1.982d6097d8",
104
104
  "react": "^19.0.0",
105
105
  "react-dom": "^19.0.0",
106
106
  "zod": ">=3.23.0"
107
107
  },
108
108
  "devDependencies": {
109
- "@open-mercato/cli": "0.6.7",
110
- "@open-mercato/shared": "0.6.7",
111
- "@open-mercato/ui": "0.6.7",
109
+ "@open-mercato/cli": "0.6.8-develop.6874.1.982d6097d8",
110
+ "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
111
+ "@open-mercato/ui": "0.6.8-develop.6874.1.982d6097d8",
112
112
  "@types/react": "^19.2.17",
113
113
  "@types/react-dom": "^19.2.3",
114
- "react": "19.2.7",
115
- "react-dom": "19.2.7",
114
+ "react": "19.2.8",
115
+ "react-dom": "19.2.8",
116
116
  "tsx": "^4.23.1",
117
117
  "zod": "^4.4.3"
118
118
  },
@@ -123,5 +123,6 @@
123
123
  "type": "git",
124
124
  "url": "https://github.com/open-mercato/open-mercato",
125
125
  "directory": "packages/ai-assistant"
126
- }
126
+ },
127
+ "stableVersion": "0.6.7"
127
128
  }
@@ -0,0 +1,126 @@
1
+ import { test, expect, request as playwrightRequest } from '@playwright/test';
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
3
+ import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
4
+ import {
5
+ deleteAgentOverridesInDb,
6
+ deleteModerationFlagsInDb,
7
+ seedModerationFlagInDb,
8
+ } from './helpers/aiAssistantFixtures';
9
+
10
+ /**
11
+ * TC-AI-MODERATION-008 — Input-moderation settings + audit API.
12
+ * Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.
13
+ *
14
+ * Surfaces under test:
15
+ * - PUT /api/ai_assistant/settings (persists `inputModeration` per agent)
16
+ * - GET /api/ai_assistant/settings (reflects per-agent effective moderation policy)
17
+ * - GET /api/ai_assistant/moderation-flags (tenant-scoped audit listing)
18
+ *
19
+ * Contract notes verified against the route handlers:
20
+ * - PUT `{ agentId, inputModeration }` returns 200 echoing `inputModeration`.
21
+ * - GET /settings exposes `agents[].moderation = { enforced, override, effective }`.
22
+ * - GET /moderation-flags returns `{ items, total, page, pageSize }`, tenant-scoped,
23
+ * gated by `ai_assistant.settings.manage`, `pageSize` capped at 100.
24
+ *
25
+ * Flags are seeded directly in the DB: a real flag is only ever born from the gate
26
+ * during a flagged LLM turn (needs a live moderation call), so seeding keeps the
27
+ * audit + isolation coverage deterministic and provider-free. This spec therefore
28
+ * only runs under the coherent app+DB harness (`yarn test:integration` / `:ephemeral`).
29
+ */
30
+
31
+ const SETTINGS = '/api/ai_assistant/settings';
32
+ const FLAGS = '/api/ai_assistant/moderation-flags';
33
+
34
+ interface FlagsResponse {
35
+ items: Array<{ id: string; tenantId: string; agentId: string; userId: string; categories: Record<string, unknown> }>;
36
+ total: number;
37
+ page: number;
38
+ pageSize: number;
39
+ }
40
+
41
+ test.describe('TC-AI-MODERATION-008: input moderation settings + audit', () => {
42
+ test('PUT inputModeration persists per agent and round-trips on/off/inherit', async ({ request }) => {
43
+ test.slow();
44
+ const adminToken = await getAuthToken(request, 'admin');
45
+ const { tenantId } = getTokenScope(adminToken);
46
+ // A synthetic agent id keeps the test independent of the (empty-in-CI) live
47
+ // agent registry; the PUT upserts an override row keyed by this id. The
48
+ // per-agent `moderation` reflection on GET (registered agents only) is
49
+ // covered by the route unit tests + the stubbed UI spec (TC-AI-MODERATION-009).
50
+ const agentId = 'it.moderation_settings';
51
+
52
+ try {
53
+ const on = await apiRequest(request, 'PUT', SETTINGS, {
54
+ token: adminToken,
55
+ data: { agentId, inputModeration: true },
56
+ });
57
+ expect(on.status(), 'PUT inputModeration=true returns 200').toBe(200);
58
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(on))?.inputModeration).toBe(true);
59
+
60
+ const off = await apiRequest(request, 'PUT', SETTINGS, {
61
+ token: adminToken,
62
+ data: { agentId, inputModeration: false },
63
+ });
64
+ expect(off.status()).toBe(200);
65
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(off))?.inputModeration).toBe(false);
66
+
67
+ const inherit = await apiRequest(request, 'PUT', SETTINGS, {
68
+ token: adminToken,
69
+ data: { agentId, inputModeration: null },
70
+ });
71
+ expect(inherit.status()).toBe(200);
72
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(inherit))?.inputModeration).toBeNull();
73
+ } finally {
74
+ await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => undefined);
75
+ }
76
+ });
77
+
78
+ test('GET /moderation-flags is tenant-scoped (cross-tenant rows are never returned)', async ({ request }) => {
79
+ test.slow();
80
+ const adminToken = await getAuthToken(request, 'admin');
81
+ const { tenantId } = getTokenScope(adminToken);
82
+ const otherTenantId = '00000000-0000-4000-8000-0000000face7';
83
+
84
+ let mineId = '';
85
+ try {
86
+ mineId = await seedModerationFlagInDb({ tenantId, agentId: 'it.moderation_audit', userId: 'it-user-mine' });
87
+ await seedModerationFlagInDb({ tenantId: otherTenantId, agentId: 'it.moderation_audit', userId: 'it-user-other' });
88
+
89
+ const res = await apiRequest(request, 'GET', `${FLAGS}?pageSize=100`, { token: adminToken });
90
+ expect(res.status()).toBe(200);
91
+ const body = await readJsonSafe<FlagsResponse>(res);
92
+ expect(body?.items, 'returns an items array').toBeTruthy();
93
+ const ids = (body?.items ?? []).map((row) => row.id);
94
+ expect(ids, 'my tenant flag is visible').toContain(mineId);
95
+ // Every returned row is scoped to the caller's tenant — the other-tenant row never leaks.
96
+ for (const row of body?.items ?? []) {
97
+ expect(row.tenantId).toBe(tenantId);
98
+ }
99
+ } finally {
100
+ await deleteModerationFlagsInDb(tenantId).catch(() => undefined);
101
+ await deleteModerationFlagsInDb(otherTenantId).catch(() => undefined);
102
+ }
103
+ });
104
+
105
+ test('GET /moderation-flags RBAC + validation gates', async ({ request, baseURL }) => {
106
+ const adminToken = await getAuthToken(request, 'admin');
107
+
108
+ // pageSize above the 100 cap is rejected by the zod query schema.
109
+ const tooLarge = await apiRequest(request, 'GET', `${FLAGS}?pageSize=101`, { token: adminToken });
110
+ expect(tooLarge.status()).toBe(400);
111
+ expect((await readJsonSafe<{ code?: string }>(tooLarge))?.code).toBe('validation_error');
112
+
113
+ // Employee carries ai_assistant.view but NOT ai_assistant.settings.manage.
114
+ const employeeToken = await getAuthToken(request, 'employee');
115
+ const denied = await apiRequest(request, 'GET', FLAGS, { token: employeeToken });
116
+ expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);
117
+
118
+ const anon = await playwrightRequest.newContext({ baseURL });
119
+ try {
120
+ const res = await anon.fetch(FLAGS, { method: 'GET' });
121
+ expect(res.status(), 'unauthenticated GET is 401').toBe(401);
122
+ } finally {
123
+ await anon.dispose();
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,161 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
3
+
4
+ /**
5
+ * TC-AI-MODERATION-009 — Moderation UI surfaces (Playwright).
6
+ * Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.
7
+ *
8
+ * Covers the two new backend UI surfaces deterministically by stubbing the
9
+ * data routes (the page chrome + components render regardless of the live
10
+ * registry / DB):
11
+ * - `/backend/config/ai-assistant/moderation-flags` — audit DataTable.
12
+ * - `/backend/config/ai-assistant/agents` — per-agent moderation section.
13
+ *
14
+ * The chat enforced→rejection and chat-off→passthrough flows are intentionally
15
+ * NOT exercised here: the moderation gate runs SERVER-SIDE inside
16
+ * `runAiAgentText`, so `page.route()` cannot stub it, and a live-provider e2e
17
+ * would be non-deterministic (it needs a real OpenAI key + model behavior),
18
+ * which `.ai/qa/AGENTS.md` forbids. Those branches are covered deterministically
19
+ * by the gate unit tests (`input-moderation-gate.test.ts`), the moderation
20
+ * service tests, and the `<AiChat>` `moderation_blocked` render test.
21
+ */
22
+
23
+ const FLAGS_PAGE = '/backend/config/ai-assistant/moderation-flags';
24
+ const AGENTS_PAGE = '/backend/config/ai-assistant/agents';
25
+
26
+ test.describe('TC-AI-MODERATION-009: moderation UI surfaces', () => {
27
+ test('audit page renders a flagged row with its category badge', async ({ page }) => {
28
+ await login(page, 'superadmin');
29
+
30
+ await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {
31
+ await route.fulfill({
32
+ status: 200,
33
+ contentType: 'application/json',
34
+ body: JSON.stringify({
35
+ items: [
36
+ {
37
+ id: 'flag-it-1',
38
+ tenantId: 't-1',
39
+ organizationId: null,
40
+ agentId: 'support.portal_assistant',
41
+ userId: 'hashed-user-it',
42
+ providerId: 'openai',
43
+ modelId: 'gpt-5-mini',
44
+ categories: { hate: { flagged: true, score: 0.97 }, violence: { flagged: false, score: 0.01 } },
45
+ createdAt: '2026-06-10T12:00:00.000Z',
46
+ },
47
+ ],
48
+ total: 1,
49
+ page: 1,
50
+ pageSize: 50,
51
+ }),
52
+ });
53
+ });
54
+
55
+ await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
56
+
57
+ await expect(page.getByText('support.portal_assistant').first()).toBeVisible({ timeout: 20_000 });
58
+ await expect(page.getByText('hashed-user-it').first()).toBeVisible();
59
+ // Only the flagged category renders as a badge; the non-flagged one is omitted.
60
+ await expect(page.getByText('hate', { exact: true }).first()).toBeVisible();
61
+ await expect(page.getByText('violence', { exact: true })).toHaveCount(0);
62
+ });
63
+
64
+ test('audit page renders the empty state when there are no flags', async ({ page }) => {
65
+ await login(page, 'superadmin');
66
+
67
+ await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {
68
+ await route.fulfill({
69
+ status: 200,
70
+ contentType: 'application/json',
71
+ body: JSON.stringify({ items: [], total: 0, page: 1, pageSize: 50 }),
72
+ });
73
+ });
74
+
75
+ await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
76
+ await expect(page.getByText(/No flagged messages/i).first()).toBeVisible({ timeout: 20_000 });
77
+ });
78
+
79
+ test('audit page redirects an unauthenticated visit to login', async ({ browser }) => {
80
+ const context = await browser.newContext();
81
+ const page = await context.newPage();
82
+ try {
83
+ await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
84
+ await page.waitForURL(/\/login/, { timeout: 15_000 });
85
+ expect(page.url()).toMatch(/\/login/);
86
+ } finally {
87
+ await context.close();
88
+ }
89
+ });
90
+
91
+ test('agent settings renders the input-moderation section (enforced badge for untrusted agents)', async ({
92
+ page,
93
+ }) => {
94
+ await login(page, 'superadmin');
95
+
96
+ const agent = {
97
+ id: 'support.portal_assistant',
98
+ moduleId: 'support',
99
+ label: 'Portal assistant',
100
+ description: 'Customer-facing support assistant.',
101
+ executionMode: 'chat',
102
+ readOnly: true,
103
+ mutationPolicy: 'read-only',
104
+ untrustedInput: true,
105
+ allowedTools: [],
106
+ acceptedMediaTypes: [],
107
+ tools: [],
108
+ };
109
+
110
+ await page.route('**/api/ai_assistant/ai/agents', async (route) => {
111
+ await route.fulfill({
112
+ status: 200,
113
+ contentType: 'application/json',
114
+ body: JSON.stringify({ agents: [agent], total: 1 }),
115
+ });
116
+ });
117
+ await page.route('**/api/ai_assistant/settings', async (route) => {
118
+ await route.fulfill({
119
+ status: 200,
120
+ contentType: 'application/json',
121
+ body: JSON.stringify({
122
+ availableProviders: [],
123
+ agents: [
124
+ {
125
+ agentId: agent.id,
126
+ moduleId: agent.moduleId,
127
+ allowRuntimeModelOverride: true,
128
+ codeDefaultProviderId: null,
129
+ codeDefaultModelId: null,
130
+ override: null,
131
+ runtimeOverrideAllowlist: {
132
+ env: null,
133
+ tenant: null,
134
+ effective: { providers: null, modelsByProvider: {}, hasRestrictions: false, tenantOverridesActive: false },
135
+ envVarNames: { providers: 'X', modelsByProvider: {} },
136
+ },
137
+ providerId: 'openai',
138
+ modelId: 'gpt-5-mini',
139
+ baseURL: null,
140
+ source: 'provider_default',
141
+ moderation: { enforced: true, override: null, effective: 'enforced' },
142
+ },
143
+ ],
144
+ }),
145
+ });
146
+ });
147
+
148
+ await page.goto(AGENTS_PAGE, { waitUntil: 'domcontentloaded' });
149
+
150
+ const moderationSection = page.locator('[data-ai-agent-moderation]');
151
+ const settingsContainer = page.locator('[data-ai-agent-settings]');
152
+ // Either the moderation section rendered (registry+detail loaded from the stub)
153
+ // or at least the settings page chrome loaded — never a hard failure.
154
+ await expect(moderationSection.or(settingsContainer).first()).toBeVisible({ timeout: 20_000 });
155
+
156
+ if (await moderationSection.first().isVisible().catch(() => false)) {
157
+ // The untrustedInput agent renders the non-editable Enforced badge.
158
+ await expect(page.locator('[data-ai-agent-moderation-enforced]').first()).toBeVisible();
159
+ }
160
+ });
161
+ });
@@ -171,6 +171,54 @@ export async function deleteTenantAllowlistInDb(tenantId: string | null): Promis
171
171
  });
172
172
  }
173
173
 
174
+ export interface SeedModerationFlagInput {
175
+ tenantId: string;
176
+ organizationId?: string | null;
177
+ agentId?: string;
178
+ userId?: string;
179
+ providerId?: string;
180
+ modelId?: string;
181
+ /** Defaults to a single flagged `hate` category. */
182
+ categories?: Record<string, { flagged: boolean; score: number }>;
183
+ }
184
+
185
+ /**
186
+ * Inserts an `ai_moderation_flags` audit row directly. A real flag is only ever
187
+ * born from the gate during a flagged LLM turn (needs a live moderation call),
188
+ * so seeding the row keeps audit-listing + tenant-isolation coverage
189
+ * deterministic and provider-free. Returns the new id for assertions/cleanup.
190
+ */
191
+ export async function seedModerationFlagInDb(input: SeedModerationFlagInput): Promise<string> {
192
+ const categories = JSON.stringify(input.categories ?? { hate: { flagged: true, score: 0.97 } });
193
+ return withClient(async (client) => {
194
+ const result = await client.query<{ id: string }>(
195
+ `insert into ai_moderation_flags
196
+ (id, tenant_id, organization_id, agent_id, user_id, provider_id, model_id, categories, created_at)
197
+ values
198
+ (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7::jsonb, now())
199
+ returning id`,
200
+ [
201
+ input.tenantId,
202
+ input.organizationId ?? null,
203
+ input.agentId ?? 'it_agent.moderation_fixture',
204
+ input.userId ?? `it-user-${randomUUID()}`,
205
+ input.providerId ?? 'openai',
206
+ input.modelId ?? 'gpt-5-mini',
207
+ categories,
208
+ ],
209
+ );
210
+ return result.rows[0].id;
211
+ });
212
+ }
213
+
214
+ /** Hard-deletes every moderation-flag row for a tenant (best-effort cleanup). */
215
+ export async function deleteModerationFlagsInDb(tenantId: string | null): Promise<void> {
216
+ if (!tenantId) return;
217
+ await withClient(async (client) => {
218
+ await client.query('delete from ai_moderation_flags where tenant_id = $1', [tenantId]);
219
+ });
220
+ }
221
+
174
222
  /**
175
223
  * Hard-deletes a conversation and its participant + message rows by the
176
224
  * client-facing `conversation_id`. The DELETE route only soft-deletes, so this
@@ -15,6 +15,7 @@ import {
15
15
  type AiAgentLoopBudgetPreset,
16
16
  } from '../../../lib/agent-runtime'
17
17
  import { AgentPolicyError } from '../../../lib/agent-tools'
18
+ import { AiModerationBlockedError, AiModerationUnavailableError } from '../../../lib/moderation'
18
19
  import { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'
19
20
  import {
20
21
  canonicalProviderId,
@@ -700,6 +701,15 @@ export async function POST(req: NextRequest): Promise<Response> {
700
701
  if (error instanceof AgentPolicyError) {
701
702
  return jsonError(statusForDenyCode(error.code), error.message, error.code)
702
703
  }
704
+ // Input pre-moderation outcomes (spec 2026-06-04). The blocked categories
705
+ // are NEVER sent to the client — only the typed code, which the chat UI
706
+ // maps to a generic translated message.
707
+ if (error instanceof AiModerationBlockedError) {
708
+ return jsonError(400, 'Input rejected by the content safety filter.', 'moderation_blocked')
709
+ }
710
+ if (error instanceof AiModerationUnavailableError) {
711
+ return jsonError(503, 'Content safety check temporarily unavailable.', 'moderation_unavailable')
712
+ }
703
713
  logger.error('AI Chat Agent — Dispatch failure', { err: error })
704
714
  return jsonError(
705
715
  500,