@open-mercato/core 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +13 -1
- package/dist/helpers/integration/api.js +29 -16
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/helpers/integration/auth.js +11 -6
- package/dist/helpers/integration/auth.js.map +3 -3
- package/dist/modules/auth/commands/roles.js +9 -12
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/catalog/ai-agents-context.js +147 -0
- package/dist/modules/catalog/ai-agents-context.js.map +7 -0
- package/dist/modules/catalog/ai-agents.js +383 -0
- package/dist/modules/catalog/ai-agents.js.map +7 -0
- package/dist/modules/catalog/ai-tools/_shared.js +318 -0
- package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
- package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/types.js +10 -0
- package/dist/modules/catalog/ai-tools/types.js.map +7 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools.js +28 -0
- package/dist/modules/catalog/ai-tools.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
- package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
- package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
- package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
- package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
- package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
- package/dist/modules/catalog/events.js +7 -4
- package/dist/modules/catalog/events.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/catalog/widgets/injection-table.js +13 -1
- package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
- package/dist/modules/customers/ai-agents-context.js +96 -0
- package/dist/modules/customers/ai-agents-context.js.map +7 -0
- package/dist/modules/customers/ai-agents.js +244 -0
- package/dist/modules/customers/ai-agents.js.map +7 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
- package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
- package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/people-pack.js +261 -0
- package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
- package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/types.js +10 -0
- package/dist/modules/customers/ai-tools/types.js.map +7 -0
- package/dist/modules/customers/ai-tools.js +20 -0
- package/dist/modules/customers/ai-tools.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection-table.js +26 -0
- package/dist/modules/customers/widgets/injection-table.js.map +7 -0
- package/dist/modules/inbox_ops/ai-tools.js +4 -0
- package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
- package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
- package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
- package/dist/modules/notifications/setup.js +13 -0
- package/dist/modules/notifications/setup.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/jest.setup.ts +18 -0
- package/package.json +5 -3
- package/src/helpers/integration/api.ts +38 -16
- package/src/helpers/integration/auth.ts +13 -6
- package/src/modules/auth/commands/roles.ts +10 -12
- package/src/modules/catalog/AGENTS.md +11 -0
- package/src/modules/catalog/ai-agents-context.ts +239 -0
- package/src/modules/catalog/ai-agents.ts +525 -0
- package/src/modules/catalog/ai-tools/_shared.ts +487 -0
- package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
- package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
- package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
- package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
- package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
- package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
- package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
- package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
- package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
- package/src/modules/catalog/ai-tools/types.ts +81 -0
- package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
- package/src/modules/catalog/ai-tools.ts +78 -0
- package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
- package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
- package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
- package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
- package/src/modules/catalog/events.ts +7 -4
- package/src/modules/catalog/i18n/de.json +17 -0
- package/src/modules/catalog/i18n/en.json +17 -0
- package/src/modules/catalog/i18n/es.json +17 -0
- package/src/modules/catalog/i18n/pl.json +17 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
- package/src/modules/catalog/widgets/injection-table.ts +12 -0
- package/src/modules/customer_accounts/i18n/de.json +5 -0
- package/src/modules/customer_accounts/i18n/en.json +5 -0
- package/src/modules/customer_accounts/i18n/es.json +5 -0
- package/src/modules/customer_accounts/i18n/pl.json +5 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
- package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
- package/src/modules/customers/AGENTS.md +13 -0
- package/src/modules/customers/ai-agents-context.ts +150 -0
- package/src/modules/customers/ai-agents.ts +355 -0
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
- package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
- package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
- package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
- package/src/modules/customers/ai-tools/people-pack.ts +369 -0
- package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
- package/src/modules/customers/ai-tools/types.ts +76 -0
- package/src/modules/customers/ai-tools.ts +34 -0
- package/src/modules/customers/i18n/de.json +25 -0
- package/src/modules/customers/i18n/en.json +25 -0
- package/src/modules/customers/i18n/es.json +25 -0
- package/src/modules/customers/i18n/pl.json +25 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
- package/src/modules/customers/widgets/injection-table.ts +41 -0
- package/src/modules/inbox_ops/ai-tools.ts +4 -0
- package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
- 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
|