@open-mercato/search 0.4.2-canary-c02407ff85

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 (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. package/watch.mjs +6 -0
@@ -0,0 +1,467 @@
1
+ import { z } from 'zod'
2
+ import type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'
3
+
4
+ /**
5
+ * AI Tools definitions for the Search module.
6
+ *
7
+ * These tool definitions are discovered by the ai-assistant module's generator
8
+ * and registered as MCP tools. The search module does not depend on ai-assistant.
9
+ *
10
+ * Tool Definition Format:
11
+ * - name: Unique tool identifier (module_action format, no dots allowed)
12
+ * - description: Human-readable description for AI clients
13
+ * - inputSchema: Zod schema for input validation
14
+ * - requiredFeatures: ACL features required to execute
15
+ * - handler: Async function that executes the tool
16
+ */
17
+
18
+ /**
19
+ * Tool context provided by the MCP server at execution time.
20
+ */
21
+ type ToolContext = {
22
+ tenantId: string | null
23
+ organizationId: string | null
24
+ userId: string | null
25
+ container: {
26
+ resolve: <T = unknown>(name: string) => T
27
+ }
28
+ userFeatures: string[]
29
+ isSuperAdmin: boolean
30
+ }
31
+
32
+ /**
33
+ * Tool definition structure.
34
+ */
35
+ type AiToolDefinition = {
36
+ name: string
37
+ description: string
38
+ inputSchema: z.ZodType<any>
39
+ requiredFeatures?: string[]
40
+ handler: (input: any, ctx: ToolContext) => Promise<unknown>
41
+ }
42
+
43
+ // =============================================================================
44
+ // Tool Definitions
45
+ // =============================================================================
46
+
47
+ const searchQueryTool: AiToolDefinition = {
48
+ name: 'search_query',
49
+ description:
50
+ 'Search across all data in Open Mercato. Searches customers, products, orders, and other entities using hybrid search (full-text, semantic, and keyword matching).',
51
+ inputSchema: z.object({
52
+ query: z.string().min(1).describe('The search query text'),
53
+ limit: z
54
+ .number()
55
+ .int()
56
+ .min(1)
57
+ .max(100)
58
+ .optional()
59
+ .default(20)
60
+ .describe('Maximum number of results to return (default: 20)'),
61
+ entityTypes: z
62
+ .array(z.string())
63
+ .optional()
64
+ .describe(
65
+ 'Filter to specific entity types (e.g., ["customers:customer_person_profile", "catalog:product"])'
66
+ ),
67
+ strategies: z
68
+ .array(z.enum(['fulltext', 'vector', 'tokens']))
69
+ .optional()
70
+ .describe('Specific search strategies to use (default: all available)'),
71
+ }),
72
+ requiredFeatures: ['search.global'],
73
+ handler: async (input, ctx) => {
74
+ if (!ctx.tenantId) {
75
+ throw new Error('Tenant context is required for search')
76
+ }
77
+
78
+ const searchService = ctx.container.resolve<{
79
+ search: (query: string, options: any) => Promise<SearchResult[]>
80
+ }>('searchService')
81
+
82
+ const results = await searchService.search(input.query, {
83
+ tenantId: ctx.tenantId,
84
+ organizationId: ctx.organizationId,
85
+ entityTypes: input.entityTypes,
86
+ strategies: input.strategies as SearchStrategyId[],
87
+ limit: input.limit,
88
+ })
89
+
90
+ return {
91
+ query: input.query,
92
+ totalResults: results.length,
93
+ results: results.map((result) => ({
94
+ entityType: result.entityId,
95
+ recordId: result.recordId,
96
+ score: Math.round(result.score * 100) / 100,
97
+ source: result.source,
98
+ title: result.presenter?.title ?? result.recordId,
99
+ subtitle: result.presenter?.subtitle,
100
+ url: result.url,
101
+ })),
102
+ }
103
+ },
104
+ }
105
+
106
+ const searchStatusTool: AiToolDefinition = {
107
+ name: 'search_status',
108
+ description:
109
+ 'Get the current status of the search module, including available search strategies and their availability.',
110
+ inputSchema: z.object({}),
111
+ requiredFeatures: ['search.view'],
112
+ handler: async (_input, ctx) => {
113
+ const searchService = ctx.container.resolve<{
114
+ getStrategies: () => Array<{
115
+ id: string
116
+ name: string
117
+ priority: number
118
+ isAvailable: () => Promise<boolean>
119
+ }>
120
+ getDefaultStrategies: () => string[]
121
+ }>('searchService')
122
+
123
+ const strategies = searchService.getStrategies()
124
+ const defaultStrategies = searchService.getDefaultStrategies()
125
+
126
+ const strategyStatus = await Promise.all(
127
+ strategies.map(async (strategy) => ({
128
+ id: strategy.id,
129
+ name: strategy.name,
130
+ priority: strategy.priority,
131
+ isAvailable: await strategy.isAvailable(),
132
+ isDefault: defaultStrategies.includes(strategy.id),
133
+ }))
134
+ )
135
+
136
+ return {
137
+ strategiesRegistered: strategies.length,
138
+ defaultStrategies,
139
+ strategies: strategyStatus,
140
+ }
141
+ },
142
+ }
143
+
144
+ // =============================================================================
145
+ // search.get - Retrieve full record details by entity type and ID
146
+ // =============================================================================
147
+
148
+ const searchGetTool: AiToolDefinition = {
149
+ name: 'search_get',
150
+ description:
151
+ 'Retrieve full record details by entity type and record ID. Use this after search_query to get complete data for a specific record.',
152
+ inputSchema: z.object({
153
+ entityType: z
154
+ .string()
155
+ .describe('The entity type (e.g., "customers:customer_company_profile", "customers:customer_deal")'),
156
+ recordId: z.string().describe('The record ID (UUID)'),
157
+ }),
158
+ requiredFeatures: ['search.view'],
159
+ handler: async (input, ctx) => {
160
+ if (!ctx.tenantId) {
161
+ throw new Error('Tenant context is required')
162
+ }
163
+
164
+ const queryEngine = ctx.container.resolve<{
165
+ query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>
166
+ }>('queryEngine')
167
+
168
+ const result = await queryEngine.query(input.entityType, {
169
+ tenantId: ctx.tenantId,
170
+ organizationId: ctx.organizationId,
171
+ filters: { id: input.recordId },
172
+ includeCustomFields: true,
173
+ page: { page: 1, pageSize: 1 },
174
+ })
175
+
176
+ const record = result.items[0] as Record<string, unknown> | undefined
177
+ if (!record) {
178
+ return {
179
+ found: false,
180
+ entityType: input.entityType,
181
+ recordId: input.recordId,
182
+ error: 'Record not found',
183
+ }
184
+ }
185
+
186
+ // Extract custom fields
187
+ const customFields: Record<string, unknown> = {}
188
+ const standardFields: Record<string, unknown> = {}
189
+ for (const [key, value] of Object.entries(record)) {
190
+ if (key.startsWith('cf:') || key.startsWith('cf_')) {
191
+ customFields[key.replace(/^cf[:_]/, '')] = value
192
+ } else {
193
+ standardFields[key] = value
194
+ }
195
+ }
196
+
197
+ // Build URL based on entity type
198
+ let url: string | null = null
199
+ const id = record.id ?? record.entity_id ?? input.recordId
200
+ if (input.entityType.includes('person')) {
201
+ url = `/backend/customers/people/${id}`
202
+ } else if (input.entityType.includes('company')) {
203
+ url = `/backend/customers/companies/${id}`
204
+ } else if (input.entityType.includes('deal')) {
205
+ url = `/backend/customers/deals/${id}`
206
+ } else if (input.entityType.includes('activity')) {
207
+ const entityId = record.entity_id ?? record.entityId
208
+ url = entityId ? `/backend/customers/companies/${entityId}#activity-${id}` : null
209
+ }
210
+
211
+ return {
212
+ found: true,
213
+ entityType: input.entityType,
214
+ recordId: input.recordId,
215
+ record: standardFields,
216
+ customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
217
+ url,
218
+ }
219
+ },
220
+ }
221
+
222
+ // =============================================================================
223
+ // search.schema - Discover searchable entities and their fields
224
+ // =============================================================================
225
+
226
+ const searchSchemaTool: AiToolDefinition = {
227
+ name: 'search_schema',
228
+ description:
229
+ 'Discover searchable entities and their fields. Use this to learn what data can be searched and what fields are available for filtering.',
230
+ inputSchema: z.object({
231
+ entityType: z
232
+ .string()
233
+ .optional()
234
+ .describe('Optional: Get schema for a specific entity type only'),
235
+ }),
236
+ requiredFeatures: ['search.view'],
237
+ handler: async (input, ctx) => {
238
+ const searchIndexer = ctx.container.resolve<{
239
+ getAllEntityConfigs: () => Array<{
240
+ entityId: string
241
+ enabled?: boolean
242
+ priority?: number
243
+ strategies?: string[]
244
+ fieldPolicy?: {
245
+ searchable?: string[]
246
+ hashOnly?: string[]
247
+ excluded?: string[]
248
+ }
249
+ }>
250
+ }>('searchIndexer')
251
+
252
+ const allConfigs = searchIndexer.getAllEntityConfigs()
253
+ const entities: Array<{
254
+ entityId: string
255
+ enabled: boolean
256
+ priority: number
257
+ strategies?: string[]
258
+ searchableFields?: string[]
259
+ hashOnlyFields?: string[]
260
+ excludedFields?: string[]
261
+ }> = []
262
+
263
+ for (const entityConfig of allConfigs) {
264
+ if (input.entityType && entityConfig.entityId !== input.entityType) {
265
+ continue
266
+ }
267
+
268
+ entities.push({
269
+ entityId: entityConfig.entityId,
270
+ enabled: entityConfig.enabled !== false,
271
+ priority: entityConfig.priority ?? 5,
272
+ strategies: entityConfig.strategies,
273
+ searchableFields: entityConfig.fieldPolicy?.searchable,
274
+ hashOnlyFields: entityConfig.fieldPolicy?.hashOnly,
275
+ excludedFields: entityConfig.fieldPolicy?.excluded,
276
+ })
277
+ }
278
+
279
+ if (input.entityType && entities.length === 0) {
280
+ return {
281
+ found: false,
282
+ entityType: input.entityType,
283
+ error: 'Entity type not configured for search',
284
+ }
285
+ }
286
+
287
+ return {
288
+ totalEntities: entities.length,
289
+ entities: entities.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)),
290
+ }
291
+ },
292
+ }
293
+
294
+ // =============================================================================
295
+ // search.aggregate - Get counts grouped by field values
296
+ // =============================================================================
297
+
298
+ const searchAggregateTool: AiToolDefinition = {
299
+ name: 'search_aggregate',
300
+ description:
301
+ 'Get record counts grouped by a field value. Useful for analytics like "how many deals by stage?" or "customers by status".',
302
+ inputSchema: z.object({
303
+ entityType: z
304
+ .string()
305
+ .describe('The entity type to aggregate (e.g., "customers:customer_deal")'),
306
+ groupBy: z
307
+ .string()
308
+ .describe('The field to group by (e.g., "status", "industry", "pipeline_stage")'),
309
+ limit: z
310
+ .number()
311
+ .int()
312
+ .min(1)
313
+ .max(100)
314
+ .optional()
315
+ .default(20)
316
+ .describe('Maximum number of buckets to return (default: 20)'),
317
+ }),
318
+ requiredFeatures: ['search.view'],
319
+ handler: async (input, ctx) => {
320
+ if (!ctx.tenantId) {
321
+ throw new Error('Tenant context is required')
322
+ }
323
+
324
+ const queryEngine = ctx.container.resolve<{
325
+ query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>
326
+ }>('queryEngine')
327
+
328
+ // Fetch records and aggregate in memory
329
+ // Note: For large datasets, this should use database GROUP BY
330
+ const result = await queryEngine.query(input.entityType, {
331
+ tenantId: ctx.tenantId,
332
+ organizationId: ctx.organizationId,
333
+ page: { page: 1, pageSize: 1000 }, // Fetch up to 1000 for aggregation
334
+ })
335
+
336
+ const counts = new Map<string | null, number>()
337
+ for (const item of result.items as Record<string, unknown>[]) {
338
+ const value = item[input.groupBy]
339
+ const key = value === null || value === undefined ? null : String(value)
340
+ counts.set(key, (counts.get(key) ?? 0) + 1)
341
+ }
342
+
343
+ const total = result.items.length
344
+ const buckets = Array.from(counts.entries())
345
+ .map(([value, count]) => ({
346
+ value,
347
+ count,
348
+ percentage: Math.round((count / total) * 100 * 100) / 100,
349
+ }))
350
+ .sort((a, b) => b.count - a.count)
351
+ .slice(0, input.limit)
352
+
353
+ return {
354
+ entityType: input.entityType,
355
+ groupBy: input.groupBy,
356
+ total,
357
+ buckets,
358
+ }
359
+ },
360
+ }
361
+
362
+ const searchReindexTool: AiToolDefinition = {
363
+ name: 'search_reindex',
364
+ description:
365
+ 'Trigger a reindex operation for search data. This rebuilds the search index for the specified entity type or all entities.',
366
+ inputSchema: z.object({
367
+ entityType: z
368
+ .string()
369
+ .optional()
370
+ .describe(
371
+ 'Specific entity type to reindex (e.g., "customers:customer_person_profile"). If not provided, reindexes all entities.'
372
+ ),
373
+ strategy: z
374
+ .enum(['fulltext', 'vector'])
375
+ .optional()
376
+ .default('fulltext')
377
+ .describe('Which search strategy to reindex (default: fulltext)'),
378
+ recreateIndex: z
379
+ .boolean()
380
+ .optional()
381
+ .default(false)
382
+ .describe('Whether to recreate the index from scratch (default: false)'),
383
+ }),
384
+ requiredFeatures: ['search.reindex'],
385
+ handler: async (input, ctx) => {
386
+ if (!ctx.tenantId) {
387
+ throw new Error('Tenant context is required for reindex')
388
+ }
389
+
390
+ const searchIndexer = ctx.container.resolve<{
391
+ reindexEntityToFulltext: (params: any) => Promise<any>
392
+ reindexAllToFulltext: (params: any) => Promise<any>
393
+ reindexEntityToVector: (params: any) => Promise<void>
394
+ reindexAllToVector: (params: any) => Promise<void>
395
+ }>('searchIndexer')
396
+
397
+ const baseParams = {
398
+ tenantId: ctx.tenantId,
399
+ organizationId: ctx.organizationId,
400
+ recreateIndex: input.recreateIndex,
401
+ useQueue: true,
402
+ }
403
+
404
+ if (input.strategy === 'vector') {
405
+ if (input.entityType) {
406
+ await searchIndexer.reindexEntityToVector({
407
+ ...baseParams,
408
+ entityId: input.entityType,
409
+ })
410
+ return {
411
+ status: 'started',
412
+ strategy: 'vector',
413
+ entityType: input.entityType,
414
+ message: `Vector reindex started for ${input.entityType}`,
415
+ }
416
+ } else {
417
+ await searchIndexer.reindexAllToVector(baseParams)
418
+ return {
419
+ status: 'started',
420
+ strategy: 'vector',
421
+ entityType: 'all',
422
+ message: 'Vector reindex started for all entities',
423
+ }
424
+ }
425
+ } else {
426
+ if (input.entityType) {
427
+ const result = await searchIndexer.reindexEntityToFulltext({
428
+ ...baseParams,
429
+ entityId: input.entityType,
430
+ })
431
+ return {
432
+ status: 'completed',
433
+ strategy: 'fulltext',
434
+ entityType: input.entityType,
435
+ ...result,
436
+ }
437
+ } else {
438
+ const result = await searchIndexer.reindexAllToFulltext(baseParams)
439
+ return {
440
+ status: 'completed',
441
+ strategy: 'fulltext',
442
+ entityType: 'all',
443
+ ...result,
444
+ }
445
+ }
446
+ }
447
+ },
448
+ }
449
+
450
+ // =============================================================================
451
+ // Export
452
+ // =============================================================================
453
+
454
+ /**
455
+ * All AI tools exported by the search module.
456
+ * Discovered by ai-assistant module's generator.
457
+ */
458
+ export const aiTools = [
459
+ searchQueryTool,
460
+ searchStatusTool,
461
+ searchGetTool,
462
+ searchSchemaTool,
463
+ searchAggregateTool,
464
+ searchReindexTool,
465
+ ]
466
+
467
+ export default aiTools
@@ -0,0 +1,77 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import type { Queue } from '@open-mercato/queue'
5
+ import type { Knex } from 'knex'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import { clearReindexLock } from '../../../../lib/reindex-lock'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
10
+
11
+ export const metadata = {
12
+ POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },
13
+ }
14
+
15
+ export async function POST(req: Request) {
16
+ const { t } = await resolveTranslations()
17
+ const auth = await getAuthFromRequest(req)
18
+ if (!auth?.tenantId) {
19
+ return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
20
+ }
21
+
22
+ const container = await createRequestContainer()
23
+ const em = container.resolve('em') as EntityManager
24
+ const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
25
+
26
+ let queue: Queue | undefined
27
+ try {
28
+ queue = container.resolve<Queue>('vectorIndexQueue')
29
+ } catch {
30
+ // Queue not available - just clear the lock
31
+ }
32
+
33
+ let jobsRemoved = 0
34
+ if (queue) {
35
+ try {
36
+ const countsBefore = await queue.getJobCounts()
37
+ jobsRemoved = countsBefore.waiting + countsBefore.active
38
+ await queue.clear()
39
+ } catch {
40
+ // Queue clear failed - continue to clear lock
41
+ }
42
+ }
43
+
44
+ await clearReindexLock(knex, auth.tenantId, 'vector', auth.orgId ?? null)
45
+
46
+ // Log the cancellation
47
+ try {
48
+ const em = container.resolve('em')
49
+ await recordIndexerLog(
50
+ { em },
51
+ {
52
+ source: 'vector',
53
+ handler: 'api:search.embeddings.reindex.cancel',
54
+ message: `Cancelled vector reindex operation (${jobsRemoved} jobs removed)`,
55
+ tenantId: auth.tenantId,
56
+ organizationId: auth.orgId ?? null,
57
+ details: { jobsRemoved },
58
+ },
59
+ )
60
+ } catch {
61
+ // Logging failure should not fail the cancel operation
62
+ }
63
+
64
+ try {
65
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
66
+ if (typeof disposable.dispose === 'function') {
67
+ await disposable.dispose()
68
+ }
69
+ } catch {
70
+ // Ignore disposal errors
71
+ }
72
+
73
+ return NextResponse.json({
74
+ ok: true,
75
+ jobsRemoved,
76
+ })
77
+ }