@open-mercato/core 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3043.1a796c3920

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 (163) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +13 -1
  3. package/dist/helpers/integration/api.js +29 -16
  4. package/dist/helpers/integration/api.js.map +2 -2
  5. package/dist/helpers/integration/auth.js +11 -6
  6. package/dist/helpers/integration/auth.js.map +3 -3
  7. package/dist/modules/auth/commands/roles.js +9 -12
  8. package/dist/modules/auth/commands/roles.js.map +2 -2
  9. package/dist/modules/catalog/ai-agents-context.js +147 -0
  10. package/dist/modules/catalog/ai-agents-context.js.map +7 -0
  11. package/dist/modules/catalog/ai-agents.js +383 -0
  12. package/dist/modules/catalog/ai-agents.js.map +7 -0
  13. package/dist/modules/catalog/ai-tools/_shared.js +318 -0
  14. package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
  15. package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
  16. package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
  17. package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
  18. package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
  19. package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
  20. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
  21. package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
  22. package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
  23. package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
  24. package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
  25. package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
  26. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
  27. package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
  28. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
  29. package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
  30. package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
  31. package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
  32. package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
  33. package/dist/modules/catalog/ai-tools/types.js +10 -0
  34. package/dist/modules/catalog/ai-tools/types.js.map +7 -0
  35. package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
  36. package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
  37. package/dist/modules/catalog/ai-tools.js +28 -0
  38. package/dist/modules/catalog/ai-tools.js.map +7 -0
  39. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
  40. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
  41. package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
  42. package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
  43. package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
  44. package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
  45. package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
  46. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  47. package/dist/modules/catalog/events.js +7 -4
  48. package/dist/modules/catalog/events.js.map +2 -2
  49. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
  50. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
  51. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
  52. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
  53. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
  54. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  55. package/dist/modules/catalog/widgets/injection-table.js +13 -1
  56. package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
  57. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
  58. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
  59. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
  60. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
  61. package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
  62. package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
  63. package/dist/modules/customers/ai-agents-context.js +96 -0
  64. package/dist/modules/customers/ai-agents-context.js.map +7 -0
  65. package/dist/modules/customers/ai-agents.js +244 -0
  66. package/dist/modules/customers/ai-agents.js.map +7 -0
  67. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
  68. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
  69. package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
  70. package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
  71. package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
  72. package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
  73. package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
  74. package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
  75. package/dist/modules/customers/ai-tools/people-pack.js +261 -0
  76. package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
  77. package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
  78. package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
  79. package/dist/modules/customers/ai-tools/types.js +10 -0
  80. package/dist/modules/customers/ai-tools/types.js.map +7 -0
  81. package/dist/modules/customers/ai-tools.js +20 -0
  82. package/dist/modules/customers/ai-tools.js.map +7 -0
  83. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
  84. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
  85. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
  86. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
  87. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
  88. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
  89. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
  90. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
  91. package/dist/modules/customers/widgets/injection-table.js +26 -0
  92. package/dist/modules/customers/widgets/injection-table.js.map +7 -0
  93. package/dist/modules/inbox_ops/ai-tools.js +4 -0
  94. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  95. package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
  96. package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
  97. package/dist/modules/notifications/setup.js +13 -0
  98. package/dist/modules/notifications/setup.js.map +7 -0
  99. package/jest.config.cjs +1 -0
  100. package/jest.setup.ts +18 -0
  101. package/package.json +5 -3
  102. package/src/helpers/integration/api.ts +38 -16
  103. package/src/helpers/integration/auth.ts +13 -6
  104. package/src/modules/auth/commands/roles.ts +10 -12
  105. package/src/modules/catalog/AGENTS.md +11 -0
  106. package/src/modules/catalog/ai-agents-context.ts +239 -0
  107. package/src/modules/catalog/ai-agents.ts +525 -0
  108. package/src/modules/catalog/ai-tools/_shared.ts +487 -0
  109. package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
  110. package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
  111. package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
  112. package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
  113. package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
  114. package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
  115. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
  116. package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
  117. package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
  118. package/src/modules/catalog/ai-tools/types.ts +81 -0
  119. package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
  120. package/src/modules/catalog/ai-tools.ts +78 -0
  121. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
  122. package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
  123. package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
  124. package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
  125. package/src/modules/catalog/events.ts +7 -4
  126. package/src/modules/catalog/i18n/de.json +17 -0
  127. package/src/modules/catalog/i18n/en.json +17 -0
  128. package/src/modules/catalog/i18n/es.json +17 -0
  129. package/src/modules/catalog/i18n/pl.json +17 -0
  130. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
  131. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
  132. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
  133. package/src/modules/catalog/widgets/injection-table.ts +12 -0
  134. package/src/modules/customer_accounts/i18n/de.json +5 -0
  135. package/src/modules/customer_accounts/i18n/en.json +5 -0
  136. package/src/modules/customer_accounts/i18n/es.json +5 -0
  137. package/src/modules/customer_accounts/i18n/pl.json +5 -0
  138. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
  139. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
  140. package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
  141. package/src/modules/customers/AGENTS.md +13 -0
  142. package/src/modules/customers/ai-agents-context.ts +150 -0
  143. package/src/modules/customers/ai-agents.ts +355 -0
  144. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
  145. package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
  146. package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
  147. package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
  148. package/src/modules/customers/ai-tools/people-pack.ts +369 -0
  149. package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
  150. package/src/modules/customers/ai-tools/types.ts +76 -0
  151. package/src/modules/customers/ai-tools.ts +34 -0
  152. package/src/modules/customers/i18n/de.json +25 -0
  153. package/src/modules/customers/i18n/en.json +25 -0
  154. package/src/modules/customers/i18n/es.json +25 -0
  155. package/src/modules/customers/i18n/pl.json +25 -0
  156. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
  157. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
  158. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
  159. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
  160. package/src/modules/customers/widgets/injection-table.ts +41 -0
  161. package/src/modules/inbox_ops/ai-tools.ts +4 -0
  162. package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
  163. package/src/modules/notifications/setup.ts +11 -0
@@ -0,0 +1,600 @@
1
+ /**
2
+ * D18 catalog AI-authoring tools (Phase 1 WS-C, Step 3.12).
3
+ *
4
+ * Five structured-output helpers the `catalog.merchandising_assistant`
5
+ * agent (Step 4.9) whitelists verbatim. These tools never write to the
6
+ * database and never open a fresh model call from inside their handlers.
7
+ * Instead, each handler assembles the tenant-scoped context the model will
8
+ * need (product bundle, attribute schema, media references) and returns
9
+ * an `{ proposal, context, outputSchemaDescriptor }` contract:
10
+ *
11
+ * - `context` — tenant-validated input for the model's
12
+ * structured-output step.
13
+ * - `outputSchemaDescriptor` — a `{ schemaName, jsonSchema }` descriptor
14
+ * the agent runtime passes into
15
+ * `runAiAgentObject` (Step 3.5) so the SAME
16
+ * agent turn that invoked the tool fills the
17
+ * `proposal` via structured output.
18
+ * - `proposal` — a zod-typed placeholder with the fields the
19
+ * model is expected to populate. Handlers
20
+ * return empty defaults; the model fills them.
21
+ *
22
+ * This preserves the "no database writes + no extra model round trip from
23
+ * a tool" contract from the brief. Any downstream mutation (e.g.
24
+ * `catalog.update_product` in Step 5.14) re-validates the proposal
25
+ * authoritatively on the server before writing.
26
+ *
27
+ * Five tools:
28
+ * - `catalog.draft_description_from_attributes`
29
+ * - `catalog.extract_attributes_from_description`
30
+ * - `catalog.draft_description_from_media`
31
+ * - `catalog.suggest_title_variants`
32
+ * - `catalog.suggest_price_adjustment`
33
+ *
34
+ * Every tool sets `isMutation: false` explicitly (spec §7 line 536 calls
35
+ * this out specifically for `suggest_price_adjustment`; the whole authoring
36
+ * pack mirrors the flag for consistency).
37
+ *
38
+ * Tenant scoping: all lookups route through the shared `_shared.ts`
39
+ * helpers (`buildProductBundle`, `resolveAttributeSchema`,
40
+ * `listPriceKindsCore`) and the shared attachment + product-price readers.
41
+ * Never touches raw `em.find` / `em.findOne`.
42
+ */
43
+ import type { EntityManager } from '@mikro-orm/postgresql'
44
+ import { z } from 'zod'
45
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
46
+ import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
47
+ import { E } from '#generated/entities.ids.generated'
48
+ import { assertTenantScope, type CatalogAiToolDefinition, type CatalogToolContext } from './types'
49
+ import {
50
+ buildProductBundle,
51
+ buildScope,
52
+ listPriceKindsCore,
53
+ resolveAttributeSchema,
54
+ resolveEm,
55
+ type AttributeSchemaResult,
56
+ type ProductBundle,
57
+ type ProductBundleResult,
58
+ } from './_shared'
59
+ import type { CatalogPricingService } from '../services/catalogPricingService'
60
+ import type { PriceRow, PricingContext } from '../lib/pricing'
61
+
62
+ /* -------------------------------------------------------------------------- */
63
+ /* Shared helpers */
64
+ /* -------------------------------------------------------------------------- */
65
+
66
+ function toJsonSchema<T>(schema: z.ZodType<T>, schemaName: string): Record<string, unknown> {
67
+ const json = z.toJSONSchema(schema) as Record<string, unknown>
68
+ return { ...json, title: schemaName }
69
+ }
70
+
71
+ type ProductContextHit = {
72
+ found: true
73
+ productId: string
74
+ bundle: ProductBundle
75
+ }
76
+
77
+ type ProductContextMiss = {
78
+ found: false
79
+ productId: string
80
+ }
81
+
82
+ async function loadProductContext(
83
+ ctx: CatalogToolContext,
84
+ tenantId: string,
85
+ productId: string,
86
+ ): Promise<ProductContextHit | ProductContextMiss> {
87
+ const em = resolveEm(ctx)
88
+ const result: ProductBundleResult = await buildProductBundle(em, ctx, tenantId, productId)
89
+ if (!result.found) {
90
+ return { found: false as const, productId }
91
+ }
92
+ return { found: true as const, productId, bundle: result }
93
+ }
94
+
95
+ function resolvePricingService(ctx: CatalogToolContext): CatalogPricingService | null {
96
+ try {
97
+ return ctx.container.resolve<CatalogPricingService>('catalogPricingService')
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /* -------------------------------------------------------------------------- */
104
+ /* catalog.draft_description_from_attributes */
105
+ /* -------------------------------------------------------------------------- */
106
+
107
+ const draftDescriptionFromAttributesInput = z.object({
108
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
109
+ tonePreference: z
110
+ .enum(['neutral', 'marketing', 'technical', 'short'])
111
+ .optional()
112
+ .describe('Preferred tone for the drafted description (default `neutral`).'),
113
+ })
114
+
115
+ const draftDescriptionFromAttributesProposal = z.object({
116
+ description: z.string().describe('Drafted product description the model should author from the attribute bundle.'),
117
+ rationale: z.string().describe('Why the model authored this description (which attributes drove it).'),
118
+ attributesUsed: z.array(z.string()).describe('Attribute keys the model actually referenced.'),
119
+ })
120
+
121
+ const DRAFT_DESCRIPTION_FROM_ATTRIBUTES_SCHEMA = 'DraftDescriptionFromAttributes'
122
+
123
+ const draftDescriptionFromAttributesTool: CatalogAiToolDefinition = {
124
+ name: 'catalog.draft_description_from_attributes',
125
+ displayName: 'Draft description from attributes',
126
+ description:
127
+ 'Structured-output helper for D18: returns the tenant-scoped product bundle (core fields + attributes + media metadata) alongside a JSON-Schema descriptor the surrounding agent turn uses via `runAiAgentObject` to draft a product description. The handler NEVER calls the model itself; it composes the context and the output shape.',
128
+ inputSchema: draftDescriptionFromAttributesInput,
129
+ requiredFeatures: ['catalog.products.view'],
130
+ tags: ['read', 'catalog', 'authoring', 'merchandising'],
131
+ isMutation: false,
132
+ handler: async (rawInput, ctx) => {
133
+ const { tenantId } = assertTenantScope(ctx)
134
+ const input = draftDescriptionFromAttributesInput.parse(rawInput)
135
+ const hit = await loadProductContext(ctx, tenantId, input.productId)
136
+ if (!hit.found) {
137
+ return { found: false as const, productId: input.productId }
138
+ }
139
+ const tonePreference = input.tonePreference ?? 'neutral'
140
+ return {
141
+ found: true as const,
142
+ proposal: {
143
+ description: '',
144
+ rationale: '',
145
+ attributesUsed: [] as string[],
146
+ },
147
+ context: {
148
+ product: hit.bundle,
149
+ tonePreference,
150
+ },
151
+ outputSchemaDescriptor: {
152
+ schemaName: DRAFT_DESCRIPTION_FROM_ATTRIBUTES_SCHEMA,
153
+ jsonSchema: toJsonSchema(draftDescriptionFromAttributesProposal, DRAFT_DESCRIPTION_FROM_ATTRIBUTES_SCHEMA),
154
+ },
155
+ }
156
+ },
157
+ }
158
+
159
+ /* -------------------------------------------------------------------------- */
160
+ /* catalog.extract_attributes_from_description */
161
+ /* -------------------------------------------------------------------------- */
162
+
163
+ const extractAttributesFromDescriptionInput = z.object({
164
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
165
+ descriptionOverride: z
166
+ .string()
167
+ .min(1)
168
+ .optional()
169
+ .describe('Optional description text to analyze instead of the product\'s stored description.'),
170
+ })
171
+
172
+ // `attributes` is modeled as a free-form `Record<string, unknown>` because
173
+ // the attribute schema is tenant-defined and heterogeneous (enum / numeric /
174
+ // boolean / string-with-unit). `z.record` emits JSON Schema
175
+ // `additionalProperties: {}` which is the intended open-object surface for
176
+ // the model; downstream validation (Step 5.14 `apply_attribute_extraction`)
177
+ // re-checks each value against the resolved schema authoritatively.
178
+ const extractAttributesFromDescriptionProposal = z.object({
179
+ attributes: z
180
+ .record(z.string(), z.unknown())
181
+ .describe('Attribute key → value pairs mapped from the description. Shape matches the resolved attribute schema; server re-validates before any write.'),
182
+ confidence: z
183
+ .number()
184
+ .min(0)
185
+ .max(1)
186
+ .describe('Model confidence (0..1) in the extracted attribute set as a whole.'),
187
+ unmapped: z
188
+ .array(z.string())
189
+ .describe('Description phrases that could not be mapped to any schema attribute.'),
190
+ })
191
+
192
+ const EXTRACT_ATTRIBUTES_FROM_DESCRIPTION_SCHEMA = 'ExtractAttributesFromDescription'
193
+
194
+ const extractAttributesFromDescriptionTool: CatalogAiToolDefinition = {
195
+ name: 'catalog.extract_attributes_from_description',
196
+ displayName: 'Extract attributes from description',
197
+ description:
198
+ 'Structured-output helper for D18: returns the product bundle, the resolved attribute schema, and the description text the model should parse. The surrounding agent turn uses `runAiAgentObject` with the emitted JSON-Schema descriptor to produce the extracted attribute map. The `attributes` output shape intentionally uses `additionalProperties: true` because tenant attribute schemas are heterogeneous; any downstream write re-validates each value against the schema authoritatively.',
199
+ inputSchema: extractAttributesFromDescriptionInput,
200
+ requiredFeatures: ['catalog.products.view'],
201
+ tags: ['read', 'catalog', 'authoring', 'merchandising'],
202
+ isMutation: false,
203
+ handler: async (rawInput, ctx) => {
204
+ const { tenantId } = assertTenantScope(ctx)
205
+ const input = extractAttributesFromDescriptionInput.parse(rawInput)
206
+ const hit = await loadProductContext(ctx, tenantId, input.productId)
207
+ if (!hit.found) {
208
+ return { found: false as const, productId: input.productId }
209
+ }
210
+ const attributeSchema: AttributeSchemaResult = await resolveAttributeSchema(
211
+ ctx,
212
+ tenantId,
213
+ input.productId,
214
+ undefined,
215
+ )
216
+ const description = input.descriptionOverride ?? hit.bundle.product.description ?? ''
217
+ return {
218
+ found: true as const,
219
+ proposal: {
220
+ attributes: {} as Record<string, unknown>,
221
+ confidence: 0,
222
+ unmapped: [] as string[],
223
+ },
224
+ context: {
225
+ product: hit.bundle,
226
+ attributeSchema,
227
+ description,
228
+ },
229
+ outputSchemaDescriptor: {
230
+ schemaName: EXTRACT_ATTRIBUTES_FROM_DESCRIPTION_SCHEMA,
231
+ jsonSchema: toJsonSchema(
232
+ extractAttributesFromDescriptionProposal,
233
+ EXTRACT_ATTRIBUTES_FROM_DESCRIPTION_SCHEMA,
234
+ ),
235
+ },
236
+ }
237
+ },
238
+ }
239
+
240
+ /* -------------------------------------------------------------------------- */
241
+ /* catalog.draft_description_from_media */
242
+ /* -------------------------------------------------------------------------- */
243
+
244
+ const draftDescriptionFromMediaInput = z.object({
245
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
246
+ userUploadedAttachmentIds: z
247
+ .array(z.string().uuid())
248
+ .max(20)
249
+ .optional()
250
+ .describe('Additional attachment IDs the user just uploaded. Cross-tenant IDs are dropped with a warning (not surfaced).'),
251
+ })
252
+
253
+ const draftDescriptionFromMediaProposal = z.object({
254
+ description: z.string().describe('Drafted description based on the media references.'),
255
+ features: z.array(z.string()).describe('Salient product features the model surfaced from the media.'),
256
+ mediaReferences: z
257
+ .array(
258
+ z.object({
259
+ attachmentId: z.string(),
260
+ note: z.string().optional(),
261
+ }),
262
+ )
263
+ .describe('Attachment IDs the model actually used, with an optional note per reference.'),
264
+ })
265
+
266
+ const DRAFT_DESCRIPTION_FROM_MEDIA_SCHEMA = 'DraftDescriptionFromMedia'
267
+
268
+ type UserMediaEntry = {
269
+ attachmentId: string
270
+ fileName: string
271
+ mediaType: string | null
272
+ size: number | null
273
+ }
274
+
275
+ async function loadUserMedia(
276
+ em: EntityManager,
277
+ ctx: CatalogToolContext,
278
+ tenantId: string,
279
+ attachmentIds: string[],
280
+ ): Promise<UserMediaEntry[]> {
281
+ if (!attachmentIds.length) return []
282
+ const unique = Array.from(new Set(attachmentIds))
283
+ const where: Record<string, unknown> = {
284
+ tenantId,
285
+ id: { $in: unique },
286
+ }
287
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
288
+ const rows = await findWithDecryption<Attachment>(
289
+ em,
290
+ Attachment,
291
+ where as any,
292
+ { limit: unique.length } as any,
293
+ buildScope(ctx, tenantId),
294
+ )
295
+ const tenantScoped = rows.filter((row) => (row.tenantId ?? null) === tenantId)
296
+ for (const dropped of unique) {
297
+ if (!tenantScoped.find((row) => row.id === dropped)) {
298
+ console.warn(`[catalog.draft_description_from_media] dropping attachment not in scope: ${dropped}`)
299
+ }
300
+ }
301
+ return tenantScoped.map((row) => ({
302
+ attachmentId: row.id,
303
+ fileName: row.fileName,
304
+ mediaType: row.mimeType ?? null,
305
+ size: row.fileSize ?? null,
306
+ }))
307
+ }
308
+
309
+ const draftDescriptionFromMediaTool: CatalogAiToolDefinition = {
310
+ name: 'catalog.draft_description_from_media',
311
+ displayName: 'Draft description from media',
312
+ description:
313
+ 'Structured-output helper for D18. Returns the product bundle, the product\'s media metadata, and any tenant-scoped user-uploaded attachment IDs (cross-tenant IDs are dropped with a warning). The handler does NOT fetch attachment bytes — the Step 3.7 attachment bridge resolves bytes when the surrounding agent turn dispatches this tool. Requires a vision-capable provider to use raw image bytes; OCR-text fallback for non-vision providers already flows through the Step 3.7 bridge.',
314
+ inputSchema: draftDescriptionFromMediaInput,
315
+ requiredFeatures: ['catalog.products.view'],
316
+ tags: ['read', 'catalog', 'authoring', 'merchandising', 'media'],
317
+ isMutation: false,
318
+ supportsAttachments: true,
319
+ handler: async (rawInput, ctx) => {
320
+ const { tenantId } = assertTenantScope(ctx)
321
+ const input = draftDescriptionFromMediaInput.parse(rawInput)
322
+ const hit = await loadProductContext(ctx, tenantId, input.productId)
323
+ if (!hit.found) {
324
+ return { found: false as const, productId: input.productId }
325
+ }
326
+ const em = resolveEm(ctx)
327
+ const userMedia = await loadUserMedia(em, ctx, tenantId, input.userUploadedAttachmentIds ?? [])
328
+ const productMedia = hit.bundle.media.map((entry) => ({
329
+ attachmentId: entry.attachmentId,
330
+ fileName: entry.fileName,
331
+ mediaType: entry.mediaType,
332
+ altText: entry.altText,
333
+ sortOrder: entry.sortOrder,
334
+ }))
335
+ return {
336
+ found: true as const,
337
+ proposal: {
338
+ description: '',
339
+ features: [] as string[],
340
+ mediaReferences: [] as Array<{ attachmentId: string; note?: string }>,
341
+ },
342
+ context: {
343
+ product: hit.bundle,
344
+ productMedia,
345
+ userMedia,
346
+ },
347
+ outputSchemaDescriptor: {
348
+ schemaName: DRAFT_DESCRIPTION_FROM_MEDIA_SCHEMA,
349
+ jsonSchema: toJsonSchema(draftDescriptionFromMediaProposal, DRAFT_DESCRIPTION_FROM_MEDIA_SCHEMA),
350
+ },
351
+ }
352
+ },
353
+ }
354
+
355
+ /* -------------------------------------------------------------------------- */
356
+ /* catalog.suggest_title_variants */
357
+ /* -------------------------------------------------------------------------- */
358
+
359
+ const suggestTitleVariantsInput = z.object({
360
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
361
+ targetStyle: z
362
+ .enum(['short', 'seo', 'marketplace'])
363
+ .describe('Voice/style for the variants (short, seo-optimized, or marketplace-friendly).'),
364
+ maxVariants: z
365
+ .number()
366
+ .int()
367
+ .min(1)
368
+ .max(5)
369
+ .optional()
370
+ .describe('Max number of variants to return. Default 3, hard-capped at 5.'),
371
+ })
372
+
373
+ const suggestTitleVariantsProposal = z.object({
374
+ variants: z
375
+ .array(
376
+ z.object({
377
+ title: z.string(),
378
+ style: z.string(),
379
+ rationale: z.string().optional(),
380
+ }),
381
+ )
382
+ .describe('Proposed title variants (respect the `maxVariants` bound).'),
383
+ })
384
+
385
+ const SUGGEST_TITLE_VARIANTS_SCHEMA = 'SuggestTitleVariants'
386
+
387
+ const suggestTitleVariantsTool: CatalogAiToolDefinition = {
388
+ name: 'catalog.suggest_title_variants',
389
+ displayName: 'Suggest title variants',
390
+ description:
391
+ 'Structured-output helper for D18: returns the product bundle and the target style. The surrounding agent turn uses `runAiAgentObject` to propose `maxVariants` (default 3, capped at 5) title variants matching the style.',
392
+ inputSchema: suggestTitleVariantsInput,
393
+ requiredFeatures: ['catalog.products.view'],
394
+ tags: ['read', 'catalog', 'authoring', 'merchandising'],
395
+ isMutation: false,
396
+ handler: async (rawInput, ctx) => {
397
+ const { tenantId } = assertTenantScope(ctx)
398
+ const input = suggestTitleVariantsInput.parse(rawInput)
399
+ const hit = await loadProductContext(ctx, tenantId, input.productId)
400
+ if (!hit.found) {
401
+ return { found: false as const, productId: input.productId }
402
+ }
403
+ const maxVariants = Math.min(input.maxVariants ?? 3, 5)
404
+ return {
405
+ found: true as const,
406
+ proposal: {
407
+ variants: [] as Array<{ title: string; style: string; rationale?: string }>,
408
+ },
409
+ context: {
410
+ product: hit.bundle,
411
+ targetStyle: input.targetStyle,
412
+ maxVariants,
413
+ },
414
+ outputSchemaDescriptor: {
415
+ schemaName: SUGGEST_TITLE_VARIANTS_SCHEMA,
416
+ jsonSchema: toJsonSchema(suggestTitleVariantsProposal, SUGGEST_TITLE_VARIANTS_SCHEMA),
417
+ },
418
+ }
419
+ },
420
+ }
421
+
422
+ /* -------------------------------------------------------------------------- */
423
+ /* catalog.suggest_price_adjustment */
424
+ /* -------------------------------------------------------------------------- */
425
+
426
+ const suggestPriceAdjustmentInput = z.object({
427
+ productId: z.string().uuid().describe('Catalog product id (UUID).'),
428
+ intent: z
429
+ .string()
430
+ .trim()
431
+ .min(1)
432
+ .describe('Human-readable intent (e.g. "raise price by 10%", "match competitor at 29.99").'),
433
+ priceKindId: z
434
+ .string()
435
+ .uuid()
436
+ .optional()
437
+ .describe('Optional price-kind id to target. When omitted, the model picks the most relevant kind from `context.availablePriceKinds`.'),
438
+ })
439
+
440
+ const suggestPriceAdjustmentProposal = z.object({
441
+ currentPrice: z
442
+ .union([
443
+ z.object({
444
+ amount: z.number(),
445
+ currency: z.string(),
446
+ priceKindId: z.string(),
447
+ }),
448
+ z.null(),
449
+ ])
450
+ .describe('Current price on the targeted kind (null if none is defined).'),
451
+ proposedPrice: z.object({
452
+ amount: z.number(),
453
+ currency: z.string(),
454
+ priceKindId: z.string(),
455
+ }),
456
+ rationale: z.string(),
457
+ constraints: z.object({
458
+ respectedPriceKindScope: z.boolean(),
459
+ respectedCurrency: z.boolean(),
460
+ }),
461
+ })
462
+
463
+ const SUGGEST_PRICE_ADJUSTMENT_SCHEMA = 'SuggestPriceAdjustment'
464
+
465
+ type ResolvedCurrentPrice = {
466
+ amount: number
467
+ currency: string
468
+ priceKindId: string
469
+ } | null
470
+
471
+ function currentPriceFromBundle(
472
+ bundle: ProductBundle,
473
+ priceKindId?: string,
474
+ ): ResolvedCurrentPrice {
475
+ const priceRows = bundle.prices.all as Array<Record<string, unknown>>
476
+ if (!priceRows.length) return null
477
+ const candidates = priceKindId
478
+ ? priceRows.filter((row) => row.priceKindId === priceKindId)
479
+ : priceRows
480
+ const pick = candidates[0] ?? null
481
+ if (!pick) return null
482
+ const rawAmount = (pick.unitPriceGross ?? pick.unitPriceNet) as string | null | undefined
483
+ const amount = rawAmount === null || rawAmount === undefined ? null : Number(rawAmount)
484
+ if (amount === null || !Number.isFinite(amount)) return null
485
+ const currency = (pick.currencyCode as string | null) ?? null
486
+ const resolvedKind = (pick.priceKindId as string | null) ?? null
487
+ if (!currency || !resolvedKind) return null
488
+ return { amount, currency, priceKindId: resolvedKind }
489
+ }
490
+
491
+ async function currentPriceFromService(
492
+ ctx: CatalogToolContext,
493
+ bundle: ProductBundle,
494
+ priceKindId?: string,
495
+ ): Promise<ResolvedCurrentPrice | 'resolver_unavailable'> {
496
+ const pricingService = resolvePricingService(ctx)
497
+ if (!pricingService) return 'resolver_unavailable'
498
+ const priceRows = bundle.prices.all as Array<Record<string, unknown>>
499
+ if (!priceRows.length) return null
500
+ const candidates = priceKindId
501
+ ? priceRows.filter((row) => row.priceKindId === priceKindId)
502
+ : priceRows
503
+ if (!candidates.length) return null
504
+ try {
505
+ const pricingContext: PricingContext = { quantity: 1, date: new Date() }
506
+ const resolved = await pricingService.resolvePrice(
507
+ candidates as unknown as PriceRow[],
508
+ pricingContext,
509
+ )
510
+ if (!resolved) return null
511
+ const rawAmount = ((resolved as any).unitPriceGross ?? (resolved as any).unitPriceNet) as
512
+ | string
513
+ | number
514
+ | null
515
+ | undefined
516
+ const amount = rawAmount === null || rawAmount === undefined ? null : Number(rawAmount)
517
+ if (amount === null || !Number.isFinite(amount)) return null
518
+ const currency = ((resolved as any).currencyCode as string | null) ?? null
519
+ const resolvedKind = ((resolved as any).priceKindId
520
+ ?? ((resolved as any).priceKind && typeof (resolved as any).priceKind === 'object'
521
+ ? (resolved as any).priceKind.id
522
+ : (resolved as any).priceKind)) as string | null | undefined
523
+ if (!currency || !resolvedKind) return null
524
+ return { amount, currency, priceKindId: resolvedKind }
525
+ } catch {
526
+ // The pricing service may throw for reasons beyond our control (config,
527
+ // resolver chain error). We fall back to the bundle view rather than
528
+ // bubbling the error out of a read-only tool.
529
+ return 'resolver_unavailable'
530
+ }
531
+ }
532
+
533
+ const suggestPriceAdjustmentTool: CatalogAiToolDefinition = {
534
+ name: 'catalog.suggest_price_adjustment',
535
+ displayName: 'Suggest price adjustment',
536
+ description:
537
+ 'Structured-output helper for D18. Returns the product bundle, the current tenant-resolved price (via `catalogPricingService.selectBestPrice` when available, otherwise a fallback projection from the bundle prices), and the list of available price kinds. The surrounding agent turn uses `runAiAgentObject` to propose a price adjustment. `isMutation: false` is explicit — this tool NEVER writes; `catalog.update_product` (Step 5.14) recalculates authoritatively on the server before any write.',
538
+ inputSchema: suggestPriceAdjustmentInput,
539
+ requiredFeatures: ['catalog.pricing.manage'],
540
+ tags: ['read', 'catalog', 'authoring', 'merchandising', 'pricing'],
541
+ isMutation: false,
542
+ handler: async (rawInput, ctx) => {
543
+ const { tenantId } = assertTenantScope(ctx)
544
+ const input = suggestPriceAdjustmentInput.parse(rawInput)
545
+ const hit = await loadProductContext(ctx, tenantId, input.productId)
546
+ if (!hit.found) {
547
+ return { found: false as const, productId: input.productId }
548
+ }
549
+ const serviceResult = await currentPriceFromService(ctx, hit.bundle, input.priceKindId)
550
+ const currentPrice: ResolvedCurrentPrice =
551
+ serviceResult === 'resolver_unavailable'
552
+ ? currentPriceFromBundle(hit.bundle, input.priceKindId)
553
+ : serviceResult
554
+ const priceKindsResult = await listPriceKindsCore(ctx, { limit: 100 }, tenantId)
555
+ const availablePriceKinds = priceKindsResult.items.map((row) => ({
556
+ id: row.id,
557
+ code: row.code,
558
+ scope: row.organizationId ? ('organization' as const) : ('tenant' as const),
559
+ }))
560
+ return {
561
+ found: true as const,
562
+ proposal: {
563
+ currentPrice,
564
+ proposedPrice: {
565
+ amount: currentPrice?.amount ?? 0,
566
+ currency: currentPrice?.currency ?? (hit.bundle.product.primaryCurrencyCode ?? 'USD'),
567
+ priceKindId: currentPrice?.priceKindId ?? (input.priceKindId ?? ''),
568
+ },
569
+ rationale: '',
570
+ constraints: {
571
+ respectedPriceKindScope: true,
572
+ respectedCurrency: true,
573
+ },
574
+ },
575
+ context: {
576
+ product: hit.bundle,
577
+ intent: input.intent,
578
+ availablePriceKinds,
579
+ },
580
+ outputSchemaDescriptor: {
581
+ schemaName: SUGGEST_PRICE_ADJUSTMENT_SCHEMA,
582
+ jsonSchema: toJsonSchema(suggestPriceAdjustmentProposal, SUGGEST_PRICE_ADJUSTMENT_SCHEMA),
583
+ },
584
+ }
585
+ },
586
+ }
587
+
588
+ /* -------------------------------------------------------------------------- */
589
+ /* Export */
590
+ /* -------------------------------------------------------------------------- */
591
+
592
+ export const authoringAiTools: CatalogAiToolDefinition[] = [
593
+ draftDescriptionFromAttributesTool,
594
+ extractAttributesFromDescriptionTool,
595
+ draftDescriptionFromMediaTool,
596
+ suggestTitleVariantsTool,
597
+ suggestPriceAdjustmentTool,
598
+ ]
599
+
600
+ export default authoringAiTools