@open-mercato/core 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1
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 +14 -0
- package/dist/modules/auth/backend/roles/[id]/edit/page.js +2 -1
- package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +2 -5
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/catalog/ai-agents.js +47 -5
- package/dist/modules/catalog/ai-agents.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +2 -5
- package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +4 -3
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +2 -9
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +2 -9
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
- package/dist/modules/customers/ai-agents.js +174 -1
- package/dist/modules/customers/ai-agents.js.map +2 -2
- package/dist/modules/customers/ai-tools/deal-analyzer-pack.js +196 -0
- package/dist/modules/customers/ai-tools/deal-analyzer-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools.js +3 -1
- package/dist/modules/customers/ai-tools.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/page.js +11 -1
- package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +11 -1
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/page.js +11 -1
- package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +37 -15
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
- package/dist/modules/customers/components/detail/ChangelogTab.js +3 -9
- package/dist/modules/customers/components/detail/ChangelogTab.js.map +2 -2
- package/dist/modules/customers/components/detail/changelogValues.js +35 -0
- package/dist/modules/customers/components/detail/changelogValues.js.map +7 -0
- package/dist/modules/customers/components/detail/customFieldUtils.js +1 -1
- package/dist/modules/customers/components/detail/customFieldUtils.js.map +1 -1
- package/dist/modules/customers/components/formConfig.js +3 -2
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +4 -9
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +2 -2
- package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.js +302 -0
- package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +2 -2
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +2 -2
- package/dist/modules/customers/widgets/injection-table.js +2 -2
- package/dist/modules/customers/widgets/injection-table.js.map +2 -2
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +2 -5
- package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js +2 -5
- package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js.map +2 -2
- package/dist/modules/resources/backend/resources/resources/[id]/page.js +2 -5
- package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js +2 -25
- package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js.map +2 -2
- package/dist/modules/sales/components/channels/ChannelOfferForm.js +2 -43
- package/dist/modules/sales/components/channels/ChannelOfferForm.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +162 -42
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/customFieldHelpers.js +4 -31
- package/dist/modules/sales/components/documents/customFieldHelpers.js.map +2 -2
- package/package.json +16 -8
- package/src/modules/auth/backend/roles/[id]/edit/page.tsx +2 -1
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +2 -5
- package/src/modules/business_rules/migrations/.snapshot-open-mercato.json +746 -394
- package/src/modules/catalog/ai-agents.ts +62 -4
- package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +2 -5
- package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2 -12
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +2 -10
- package/src/modules/catalog/migrations/.snapshot-open-mercato.json +2519 -1307
- package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +828 -834
- package/src/modules/customers/AGENTS.md +6 -1
- package/src/modules/customers/ai-agents.ts +213 -6
- package/src/modules/customers/ai-tools/deal-analyzer-pack.ts +286 -0
- package/src/modules/customers/ai-tools.ts +2 -0
- package/src/modules/customers/backend/customers/companies/page.tsx +10 -0
- package/src/modules/customers/backend/customers/deals/page.tsx +10 -0
- package/src/modules/customers/backend/customers/people/page.tsx +10 -0
- package/src/modules/customers/commands/companies.ts +38 -14
- package/src/modules/customers/components/detail/AiActionChips.tsx +2 -2
- package/src/modules/customers/components/detail/ChangelogTab.tsx +3 -10
- package/src/modules/customers/components/detail/changelogValues.ts +42 -0
- package/src/modules/customers/components/detail/customFieldUtils.ts +1 -1
- package/src/modules/customers/components/formConfig.tsx +3 -2
- package/src/modules/customers/i18n/de.json +7 -0
- package/src/modules/customers/i18n/en.json +7 -0
- package/src/modules/customers/i18n/es.json +7 -0
- package/src/modules/customers/i18n/pl.json +7 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +2313 -2336
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +4 -6
- package/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.tsx +358 -0
- package/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.ts +34 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +2 -2
- package/src/modules/customers/widgets/injection-table.ts +12 -9
- package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +554 -210
- package/src/modules/dictionaries/migrations/.snapshot-open-mercato.json +146 -146
- package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +2 -5
- package/src/modules/directory/backend/directory/tenants/[id]/edit/page.tsx +2 -5
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +273 -140
- package/src/modules/feature_toggles/migrations/.snapshot-open-mercato.json +242 -137
- package/src/modules/integrations/migrations/.snapshot-open-mercato.json +498 -195
- package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +158 -157
- package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +274 -129
- package/src/modules/planner/migrations/.snapshot-open-mercato.json +327 -157
- package/src/modules/progress/AGENTS.md +72 -0
- package/src/modules/resources/backend/resources/resources/[id]/page.tsx +2 -5
- package/src/modules/resources/migrations/.snapshot-open-mercato.json +920 -470
- package/src/modules/sales/backend/sales/channels/[channelId]/edit/page.tsx +2 -27
- package/src/modules/sales/components/channels/ChannelOfferForm.tsx +2 -49
- package/src/modules/sales/components/documents/LineItemDialog.tsx +214 -78
- package/src/modules/sales/components/documents/customFieldHelpers.ts +4 -43
- package/src/modules/sales/i18n/de.json +3 -0
- package/src/modules/sales/i18n/en.json +3 -0
- package/src/modules/sales/i18n/es.json +3 -0
- package/src/modules/sales/i18n/pl.json +3 -0
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +4740 -4778
- package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +340 -81
- package/src/modules/staff/migrations/.snapshot-open-mercato.json +1439 -729
|
@@ -102,7 +102,12 @@ This module is the reference implementation for the AI framework. Copy `ai-agent
|
|
|
102
102
|
|----------|------|--------|---------|
|
|
103
103
|
| `customers.account_assistant` | chat | read-only (mutation-capable via per-tenant override that unlocks `customers.update_deal_stage`; see `packages/core/src/modules/customers/ai-agents.ts` for the whitelist and prompt) | Operator-facing assistant that explores people, companies, deals, activities, tasks, addresses, tags, and settings through the customers tool pack. |
|
|
104
104
|
| `customers.update_deal_stage` | tool (mutation) | `destructive-confirm-required` — goes through `prepareMutation` + the approval card | Moves a deal between stages / flips status between open, won, lost. Declared via `defineAiTool` in `ai-tools.ts` and exposed only when the tenant mutation-policy override promotes the agent above read-only. |
|
|
105
|
+
| `customers.deal_analyzer` | chat, `executionEngine: 'stream-text'` | `confirm-required` | Multi-step agentic demo that exercises loop primitives from PRs #1856..#1869. Calls `customers.analyze_deals`, ranks deals by health score, then calls `customers.update_deal_stage` for the highest-value stalled deal — loop stops immediately after (loop.stopWhen). `loop.prepareStep` scopes the active tool set by step without overriding the model. It intentionally leaves `defaultProvider` and `defaultModel` unset so runtime resolution uses `OM_AI_PROVIDER` / `OM_AI_MODEL` or tenant/runtime overrides. |
|
|
106
|
+
| `customers.deal_analyzer_tool_loop` | chat, `executionEngine: 'tool-loop-agent'` | `confirm-required` | Sibling agent identical to `customers.deal_analyzer` but dispatched via `ToolLoopAgent`. Proves the mutation-approval gate holds for both execution engines (TC-AI-AGENT-LOOP-006). |
|
|
105
107
|
|
|
106
108
|
`<AiChat agent="customers.account_assistant" />` is injected in two places (both live in `widgets/injection/`):
|
|
107
|
-
- People list: `data-table:customers.people.list:
|
|
109
|
+
- People list: `data-table:customers.people.list:search-trailing` via the `ai-assistant-trigger` widget.
|
|
108
110
|
- Deal detail: `detail:customers.deal:header` via the `ai-deal-detail-trigger` widget.
|
|
111
|
+
|
|
112
|
+
`<AiChat agent="customers.deal_analyzer" />` is injected on the Deals list page:
|
|
113
|
+
- Deals list: `data-table:customers.deals.list:search-trailing` via the `ai-deal-analyzer-trigger` widget. Selected deal IDs are passed as `pageContext.recordId` (comma-separated UUIDs) so the agent can scope its `customers.analyze_deals` call to the operator's current selection.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module-root AI agent contribution for the customers module.
|
|
3
3
|
*
|
|
4
|
+
* See /framework/ai-assistant/agents for the structured PromptTemplate
|
|
5
|
+
* convention and the per-tenant override path that can downgrade
|
|
6
|
+
* mutationPolicy to read-only.
|
|
7
|
+
*
|
|
4
8
|
* The generator walks every module root for a top-level `ai-agents.ts` and
|
|
5
9
|
* takes the default/`aiAgents` export as the agent contribution. The
|
|
6
10
|
* `customers.account_assistant` agent explores people / companies / deals /
|
|
@@ -15,11 +19,7 @@
|
|
|
15
19
|
*
|
|
16
20
|
* Prompt is declared as a structured `PromptTemplate` (not a flat string)
|
|
17
21
|
* per spec §8 with the seven named sections: ROLE, SCOPE, DATA, TOOLS,
|
|
18
|
-
* ATTACHMENTS, MUTATION POLICY, RESPONSE STYLE.
|
|
19
|
-
* fed into `systemPrompt` so the existing runtime continues to work, and
|
|
20
|
-
* the structured template is additionally exported so downstream Phases
|
|
21
|
-
* (5.3 prompt-override merge, 5.2 resolvePageContext hydration) can
|
|
22
|
-
* address sections by name.
|
|
22
|
+
* ATTACHMENTS, MUTATION POLICY, RESPONSE STYLE.
|
|
23
23
|
*/
|
|
24
24
|
import type {
|
|
25
25
|
AiAgentDefinition,
|
|
@@ -303,6 +303,213 @@ const agent: AiAgentDefinition = {
|
|
|
303
303
|
resolvePageContext,
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
|
|
306
|
+
// customers.deal_analyzer — multi-step agentic loop demo.
|
|
307
|
+
// See /framework/ai-assistant/agents → "Deal Analyzer demo" for the loop
|
|
308
|
+
// primitives exercised here; the sibling tool-loop-agent below proves both
|
|
309
|
+
// execution engines honor the mutation gate.
|
|
310
|
+
|
|
311
|
+
type DealAnalyzerPromptSectionName =
|
|
312
|
+
| 'role'
|
|
313
|
+
| 'scope'
|
|
314
|
+
| 'data'
|
|
315
|
+
| 'tools'
|
|
316
|
+
| 'mutationPolicy'
|
|
317
|
+
| 'responseStyle'
|
|
318
|
+
|
|
319
|
+
interface DealAnalyzerPromptSection {
|
|
320
|
+
name: DealAnalyzerPromptSectionName
|
|
321
|
+
content: string
|
|
322
|
+
order: number
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const DEAL_ANALYZER_PROMPT_SECTIONS: DealAnalyzerPromptSection[] = [
|
|
326
|
+
{
|
|
327
|
+
name: 'role',
|
|
328
|
+
order: 1,
|
|
329
|
+
content: [
|
|
330
|
+
'ROLE',
|
|
331
|
+
'You are the Deal Analyzer inside Open Mercato. You are a multi-step agentic',
|
|
332
|
+
'assistant that analyzes the health of a tenant\'s open deals, surfaces stalled',
|
|
333
|
+
'opportunities, and proposes pipeline stage transitions for operator approval.',
|
|
334
|
+
].join('\n'),
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'scope',
|
|
338
|
+
order: 2,
|
|
339
|
+
content: [
|
|
340
|
+
'SCOPE',
|
|
341
|
+
'Stay inside the customers module. Respect tenant and organization isolation.',
|
|
342
|
+
'ALWAYS call customers.analyze_deals as your FIRST tool call — do not skip this.',
|
|
343
|
+
'Reason about stalled deals: any deal with no activity for more than 14 days',
|
|
344
|
+
'is considered stalled. For each stalled deal with a value greater than $5,000',
|
|
345
|
+
'propose a stage move via customers.update_deal_stage.',
|
|
346
|
+
'After calling customers.update_deal_stage, finish with a concise conclusion',
|
|
347
|
+
'summarizing the analysis and the approval action the operator should review.',
|
|
348
|
+
].join('\n'),
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: 'data',
|
|
352
|
+
order: 3,
|
|
353
|
+
content: [
|
|
354
|
+
'DATA',
|
|
355
|
+
'You can read: customers.deal via customers.analyze_deals (analytical summary)',
|
|
356
|
+
'and customers.list_deals / customers.get_deal (full detail).',
|
|
357
|
+
'Use customers.analyze_deals first — it returns a ranked list of deals by',
|
|
358
|
+
'health score (lowest = most at risk) with last-activity information.',
|
|
359
|
+
'Use customers.list_activities to get more detail on a specific deal\'s activity.',
|
|
360
|
+
'Use search.hybrid_search only for free-text queries spanning multiple entity types.',
|
|
361
|
+
'CRITICAL: Only use IDs returned by a prior tool call. Never invent or guess UUIDs.',
|
|
362
|
+
].join('\n'),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'tools',
|
|
366
|
+
order: 4,
|
|
367
|
+
content: [
|
|
368
|
+
'TOOLS',
|
|
369
|
+
'Primary tools for this agent (call in this order on each turn):',
|
|
370
|
+
'1. customers.analyze_deals — analytical overview of deals ranked by health score.',
|
|
371
|
+
' Supply dealStageFilter="open" to restrict to open deals.',
|
|
372
|
+
'2. customers.update_deal_stage — propose a stage move for a stalled high-value deal.',
|
|
373
|
+
' The runtime intercepts this via the pending-action gate; do NOT claim the change',
|
|
374
|
+
' is saved until the mutation-result-card arrives.',
|
|
375
|
+
'Secondary read tools (use when you need more detail):',
|
|
376
|
+
'- customers.list_deals, customers.get_deal, customers.list_activities',
|
|
377
|
+
'- search.hybrid_search, meta.describe_agent',
|
|
378
|
+
].join('\n'),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: 'mutationPolicy',
|
|
382
|
+
order: 5,
|
|
383
|
+
content: [
|
|
384
|
+
'MUTATION POLICY',
|
|
385
|
+
'This agent ships with mutationPolicy: "confirm-required". Every write goes through',
|
|
386
|
+
'the pending-action approval card and only persists after the operator confirms it.',
|
|
387
|
+
'After calling customers.update_deal_stage, explain whether a pending approval',
|
|
388
|
+
'card was created or whether the proposed move could not be prepared.',
|
|
389
|
+
'Do NOT call update_deal_stage more than once per turn.',
|
|
390
|
+
'If a per-tenant override has downgraded this agent to read-only, tell the operator',
|
|
391
|
+
'the write is locked and point to /backend/customers/deals/<id>.',
|
|
392
|
+
].join('\n'),
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: 'responseStyle',
|
|
396
|
+
order: 6,
|
|
397
|
+
content: [
|
|
398
|
+
'RESPONSE STYLE',
|
|
399
|
+
'',
|
|
400
|
+
'═══════════════════════════════════════════════════════════════════════',
|
|
401
|
+
'RULE #1 — DEAL RECORD CARDS ARE MANDATORY',
|
|
402
|
+
'═══════════════════════════════════════════════════════════════════════',
|
|
403
|
+
'For every deal you surface in your analysis you MUST emit an open-mercato:deal',
|
|
404
|
+
'fenced card. Use the deal id, title, value, and stage from the tool output.',
|
|
405
|
+
'Always populate href with /backend/customers/deals/<id>.',
|
|
406
|
+
'',
|
|
407
|
+
'Card schema: { "id", "title", "status"?, "stage"?, "amount"?, "currency"?,',
|
|
408
|
+
' "closeDate"?, "ownerName"?, "personName"?, "companyName"?,',
|
|
409
|
+
' "description"?, "tags"?, "href" }',
|
|
410
|
+
'',
|
|
411
|
+
'═══════════════════════════════════════════════════════════════════════',
|
|
412
|
+
'RULE #2 — Analysis format',
|
|
413
|
+
'═══════════════════════════════════════════════════════════════════════',
|
|
414
|
+
'Lead with a one-paragraph summary of the deal portfolio health, then emit one',
|
|
415
|
+
'deal card per at-risk deal (sorted by health score ascending). After the cards,',
|
|
416
|
+
'propose exactly one stage move for the highest-value stalled deal and call',
|
|
417
|
+
'customers.update_deal_stage. Then finish with a short conclusion naming the',
|
|
418
|
+
'highest-value stalled deal, the recommended move, and the approval status.',
|
|
419
|
+
].join('\n'),
|
|
420
|
+
},
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
function compileDealAnalyzerPrompt(): string {
|
|
424
|
+
return DEAL_ANALYZER_PROMPT_SECTIONS.slice()
|
|
425
|
+
.sort((a, b) => a.order - b.order)
|
|
426
|
+
.map((section) => section.content.trim())
|
|
427
|
+
.join('\n\n')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const DEAL_ANALYZER_ALLOWED_TOOLS: readonly string[] = [
|
|
431
|
+
'customers.analyze_deals',
|
|
432
|
+
'customers.update_deal_stage',
|
|
433
|
+
'customers.list_deals',
|
|
434
|
+
'customers.get_deal',
|
|
435
|
+
'customers.list_activities',
|
|
436
|
+
'search.hybrid_search',
|
|
437
|
+
'meta.describe_agent',
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
function buildDealAnalyzerPrepareStep() {
|
|
441
|
+
return async function dealAnalyzerPrepareStep(state: { stepNumber: number }) {
|
|
442
|
+
if (state.stepNumber === 0) {
|
|
443
|
+
return {
|
|
444
|
+
activeTools: [
|
|
445
|
+
'customers.analyze_deals',
|
|
446
|
+
'customers.list_deals',
|
|
447
|
+
'customers.get_deal',
|
|
448
|
+
'customers.list_activities',
|
|
449
|
+
'search.hybrid_search',
|
|
450
|
+
'meta.describe_agent',
|
|
451
|
+
],
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { activeTools: [...DEAL_ANALYZER_ALLOWED_TOOLS] }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const dealAnalyzer: AiAgentDefinition = {
|
|
459
|
+
id: 'customers.deal_analyzer',
|
|
460
|
+
moduleId: 'customers',
|
|
461
|
+
label: 'Deal Analyzer',
|
|
462
|
+
description:
|
|
463
|
+
'Multi-step CRM agent that analyzes deals, surfaces stalled opportunities, and proposes stage transitions for operator approval.',
|
|
464
|
+
systemPrompt: compileDealAnalyzerPrompt(),
|
|
465
|
+
allowedTools: [...DEAL_ANALYZER_ALLOWED_TOOLS],
|
|
466
|
+
executionMode: 'chat',
|
|
467
|
+
executionEngine: 'stream-text',
|
|
468
|
+
allowRuntimeOverride: true,
|
|
469
|
+
readOnly: false,
|
|
470
|
+
mutationPolicy: 'confirm-required',
|
|
471
|
+
requiredFeatures: ['customers.deals.view'],
|
|
472
|
+
uiParts: ['open-mercato:deal'],
|
|
473
|
+
keywords: ['deal', 'pipeline', 'stalled', 'crm', 'analysis', 'health'],
|
|
474
|
+
domain: 'customers',
|
|
475
|
+
loop: {
|
|
476
|
+
maxSteps: 12,
|
|
477
|
+
prepareStep: buildDealAnalyzerPrepareStep() as AiAgentDefinition['loop'] extends undefined
|
|
478
|
+
? never
|
|
479
|
+
: NonNullable<AiAgentDefinition['loop']>['prepareStep'],
|
|
480
|
+
budget: {
|
|
481
|
+
maxToolCalls: 12,
|
|
482
|
+
maxWallClockMs: 60_000,
|
|
483
|
+
},
|
|
484
|
+
allowRuntimeOverride: true,
|
|
485
|
+
},
|
|
486
|
+
dataCapabilities: {
|
|
487
|
+
entities: ['customers.deal', 'customers.activity'],
|
|
488
|
+
operations: ['read', 'search', 'aggregate'],
|
|
489
|
+
},
|
|
490
|
+
suggestions: [
|
|
491
|
+
{
|
|
492
|
+
label: 'Analyze stalled deals',
|
|
493
|
+
prompt: 'Analyze stalled deals from the last 30 days and propose a stage move for the highest value one',
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
label: 'Show at-risk pipeline',
|
|
497
|
+
prompt: 'Show me deals with no activity in the last 14 days worth more than $5,000',
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Sibling agent identical to customers.deal_analyzer except for
|
|
503
|
+
// executionEngine: 'tool-loop-agent' (TC-AI-AGENT-LOOP-006).
|
|
504
|
+
const dealAnalyzerToolLoop: AiAgentDefinition = {
|
|
505
|
+
...dealAnalyzer,
|
|
506
|
+
id: 'customers.deal_analyzer_tool_loop',
|
|
507
|
+
label: 'Deal Analyzer (ToolLoopAgent)',
|
|
508
|
+
description:
|
|
509
|
+
'Same as customers.deal_analyzer but dispatched via the ToolLoopAgent engine. Used by TC-AI-AGENT-LOOP-006 mutation-gate proof scenario.',
|
|
510
|
+
executionEngine: 'tool-loop-agent',
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export const aiAgents: AiAgentDefinition[] = [agent, dealAnalyzer, dealAnalyzerToolLoop]
|
|
307
514
|
|
|
308
515
|
export default aiAgents
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `customers.analyze_deals` — analytical read-only tool for the deal_analyzer
|
|
3
|
+
* agent (Step d1 of the deal-analyzer-demo).
|
|
4
|
+
*
|
|
5
|
+
* Reads deals scoped to the caller tenant/org, enriches each deal with the
|
|
6
|
+
* most recent activity timestamp (days-since-last-activity), computes a simple
|
|
7
|
+
* healthScore, and returns a ranked list. The handler issues two bounded DB
|
|
8
|
+
* reads (deals + activities) — no N+1.
|
|
9
|
+
*
|
|
10
|
+
* `customers.update_deal_stage` already exists in deals-pack.ts and is
|
|
11
|
+
* imported and re-exported here so the deal_analyzer agent's `allowedTools`
|
|
12
|
+
* list can reference both under a single pack. The tool itself is NOT
|
|
13
|
+
* redeclared here.
|
|
14
|
+
*/
|
|
15
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
18
|
+
import {
|
|
19
|
+
CustomerDeal,
|
|
20
|
+
CustomerActivity,
|
|
21
|
+
CustomerDealPersonLink,
|
|
22
|
+
CustomerEntity,
|
|
23
|
+
} from '../data/entities'
|
|
24
|
+
import {
|
|
25
|
+
assertTenantScope,
|
|
26
|
+
type CustomersAiToolDefinition,
|
|
27
|
+
type CustomersToolContext,
|
|
28
|
+
} from './types'
|
|
29
|
+
|
|
30
|
+
function refIdOf(ref: unknown): string | undefined {
|
|
31
|
+
if (!ref) return undefined
|
|
32
|
+
if (typeof ref === 'string') return ref
|
|
33
|
+
if (typeof ref === 'object' && typeof (ref as { id?: unknown }).id === 'string') {
|
|
34
|
+
return (ref as { id: string }).id
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveEm(ctx: CustomersToolContext): EntityManager {
|
|
40
|
+
return ctx.container.resolve<EntityManager>('em')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clamp(value: number, min: number, max: number): number {
|
|
44
|
+
return Math.min(Math.max(value, min), max)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute a health score for a deal:
|
|
49
|
+
* healthScore = clamp((30 - daysSinceLastActivity) / 30 * 100, 0, 100)
|
|
50
|
+
* A deal with activity within the last 30 days scores > 0. Deals with no
|
|
51
|
+
* activity default to daysSinceLastActivity = activityWindow so they score 0.
|
|
52
|
+
* Exported for unit testing.
|
|
53
|
+
*/
|
|
54
|
+
export function computeHealthScore(daysSinceLastActivity: number): number {
|
|
55
|
+
return clamp(((30 - daysSinceLastActivity) / 30) * 100, 0, 100)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const analyzeDealsInput = z.object({
|
|
59
|
+
dealStageFilter: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe(
|
|
63
|
+
'Optional pipeline stage name or status slug to restrict results (e.g. "open", "qualification", "negotiation").',
|
|
64
|
+
),
|
|
65
|
+
daysOfActivityWindow: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(365)
|
|
70
|
+
.default(30)
|
|
71
|
+
.describe(
|
|
72
|
+
'Number of days to look back for "last activity" calculation. Deals with no activity in this window are flagged as stalled. Default 30.',
|
|
73
|
+
),
|
|
74
|
+
limit: z
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.min(1)
|
|
78
|
+
.max(100)
|
|
79
|
+
.default(25)
|
|
80
|
+
.describe('Maximum number of deals to return, ranked by healthScore ascending (most at-risk first). Default 25.'),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
type AnalyzeDealsInput = z.infer<typeof analyzeDealsInput>
|
|
84
|
+
|
|
85
|
+
type DealAnalysisItem = {
|
|
86
|
+
id: string
|
|
87
|
+
title: string
|
|
88
|
+
value: number | null
|
|
89
|
+
valueCurrency: string | null
|
|
90
|
+
stage: string | null
|
|
91
|
+
status: string
|
|
92
|
+
daysSinceLastActivity: number
|
|
93
|
+
primaryContact: string | null
|
|
94
|
+
healthScore: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type AnalyzeDealsOutput = {
|
|
98
|
+
deals: DealAnalysisItem[]
|
|
99
|
+
totalAnalyzed: number
|
|
100
|
+
stalledCount: number
|
|
101
|
+
windowDays: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function msTodays(ms: number): number {
|
|
105
|
+
return Math.floor(ms / (1000 * 60 * 60 * 24))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const analyzeDealsToolDefinition: CustomersAiToolDefinition<AnalyzeDealsInput, AnalyzeDealsOutput> = {
|
|
109
|
+
name: 'customers.analyze_deals',
|
|
110
|
+
displayName: 'Analyze deals',
|
|
111
|
+
description:
|
|
112
|
+
'Fetch and analyze open deals for the caller tenant. Returns each deal with its days-since-last-activity and a health score (0–100, lower = more stalled). Results are ranked most-at-risk first.',
|
|
113
|
+
inputSchema: analyzeDealsInput as z.ZodType<AnalyzeDealsInput>,
|
|
114
|
+
requiredFeatures: ['customers.deals.view'],
|
|
115
|
+
tags: ['read', 'customers', 'analytics'],
|
|
116
|
+
isMutation: false,
|
|
117
|
+
handler: async (rawInput, ctx): Promise<AnalyzeDealsOutput> => {
|
|
118
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
119
|
+
const input = analyzeDealsInput.parse(rawInput)
|
|
120
|
+
const em = resolveEm(ctx)
|
|
121
|
+
const scope = { tenantId, organizationId: ctx.organizationId }
|
|
122
|
+
|
|
123
|
+
// Build deal filter
|
|
124
|
+
const dealWhere: Record<string, unknown> = {
|
|
125
|
+
tenantId,
|
|
126
|
+
deletedAt: null,
|
|
127
|
+
}
|
|
128
|
+
if (ctx.organizationId) dealWhere.organizationId = ctx.organizationId
|
|
129
|
+
// Apply stage/status filter when provided. `$or` is composed with the
|
|
130
|
+
// outer `deletedAt: null` filter — both must hold — so the caller-supplied
|
|
131
|
+
// value matches either the canonical status slug or the stage label.
|
|
132
|
+
if (input.dealStageFilter) {
|
|
133
|
+
const filterValue = input.dealStageFilter.trim()
|
|
134
|
+
if (filterValue.length) {
|
|
135
|
+
dealWhere.$or = [
|
|
136
|
+
{ status: filterValue },
|
|
137
|
+
{ pipelineStage: filterValue },
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const deals = await findWithDecryption<CustomerDeal>(
|
|
143
|
+
em,
|
|
144
|
+
CustomerDeal,
|
|
145
|
+
dealWhere as any,
|
|
146
|
+
{
|
|
147
|
+
orderBy: { updatedAt: 'desc' },
|
|
148
|
+
limit: Math.min(input.limit * 4, 400), // Fetch extra so we can rank properly
|
|
149
|
+
},
|
|
150
|
+
scope,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if (!deals.length) {
|
|
154
|
+
return { deals: [], totalAnalyzed: 0, stalledCount: 0, windowDays: input.daysOfActivityWindow }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const dealIds = deals.map((d) => d.id)
|
|
158
|
+
|
|
159
|
+
// Fetch most recent activity per deal within the window in one batch.
|
|
160
|
+
// CustomerActivity has encrypted columns (subject, body); use the
|
|
161
|
+
// tenant-aware helper even though this query only reads occurredAt/deal,
|
|
162
|
+
// so future readers cannot accidentally surface ciphertext.
|
|
163
|
+
const activityWhere: Record<string, unknown> = {
|
|
164
|
+
deal: { $in: dealIds },
|
|
165
|
+
tenantId,
|
|
166
|
+
}
|
|
167
|
+
if (ctx.organizationId) activityWhere.organizationId = ctx.organizationId
|
|
168
|
+
const activities = await findWithDecryption<CustomerActivity>(
|
|
169
|
+
em,
|
|
170
|
+
CustomerActivity,
|
|
171
|
+
activityWhere as any,
|
|
172
|
+
{
|
|
173
|
+
orderBy: { occurredAt: 'desc' },
|
|
174
|
+
limit: 1000,
|
|
175
|
+
},
|
|
176
|
+
scope,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const lastActivityByDeal = new Map<string, Date>()
|
|
180
|
+
for (const activity of activities) {
|
|
181
|
+
const refId = refIdOf((activity as { deal?: unknown }).deal)
|
|
182
|
+
if (!refId) continue
|
|
183
|
+
const activityDate = activity.occurredAt ?? activity.createdAt
|
|
184
|
+
if (!activityDate) continue
|
|
185
|
+
const existing = lastActivityByDeal.get(refId)
|
|
186
|
+
if (!existing || activityDate > existing) {
|
|
187
|
+
lastActivityByDeal.set(refId, activityDate)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Fetch primary contacts for deals in one batch. CustomerDealPersonLink
|
|
192
|
+
// has no encrypted columns, but the linked CustomerEntity.display_name is
|
|
193
|
+
// encrypted, so resolve names via findWithDecryption rather than
|
|
194
|
+
// populating the relation through raw em.find.
|
|
195
|
+
const personLinkWhere: Record<string, unknown> = {
|
|
196
|
+
deal: { $in: dealIds },
|
|
197
|
+
}
|
|
198
|
+
const personLinks = await em.find(
|
|
199
|
+
CustomerDealPersonLink,
|
|
200
|
+
personLinkWhere as any,
|
|
201
|
+
{ limit: 500 },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const linksByDeal = new Map<string, string>() // dealId → personId (first link wins)
|
|
205
|
+
for (const link of personLinks) {
|
|
206
|
+
const dealRefId = refIdOf((link as { deal?: unknown }).deal)
|
|
207
|
+
const personRefId = refIdOf((link as { person?: unknown }).person)
|
|
208
|
+
if (!dealRefId || !personRefId) continue
|
|
209
|
+
if (!linksByDeal.has(dealRefId)) linksByDeal.set(dealRefId, personRefId)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const primaryContactByDeal = new Map<string, string>()
|
|
213
|
+
const personIds = Array.from(new Set(linksByDeal.values()))
|
|
214
|
+
if (personIds.length) {
|
|
215
|
+
const personWhere: Record<string, unknown> = {
|
|
216
|
+
id: { $in: personIds },
|
|
217
|
+
tenantId,
|
|
218
|
+
}
|
|
219
|
+
if (ctx.organizationId) personWhere.organizationId = ctx.organizationId
|
|
220
|
+
const persons = await findWithDecryption<CustomerEntity>(
|
|
221
|
+
em,
|
|
222
|
+
CustomerEntity,
|
|
223
|
+
personWhere as any,
|
|
224
|
+
undefined,
|
|
225
|
+
scope,
|
|
226
|
+
)
|
|
227
|
+
const nameByPersonId = new Map<string, string>()
|
|
228
|
+
for (const person of persons) {
|
|
229
|
+
const name = person.displayName
|
|
230
|
+
if (typeof name === 'string' && name.length) {
|
|
231
|
+
nameByPersonId.set(person.id, name)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const [dealId, personId] of linksByDeal) {
|
|
235
|
+
const name = nameByPersonId.get(personId)
|
|
236
|
+
if (name) primaryContactByDeal.set(dealId, name)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const now = Date.now()
|
|
241
|
+
const analyzed: DealAnalysisItem[] = deals.map((deal) => {
|
|
242
|
+
const lastActivity = lastActivityByDeal.get(deal.id)
|
|
243
|
+
const daysSinceLastActivity = lastActivity
|
|
244
|
+
? msTodays(now - lastActivity.getTime())
|
|
245
|
+
: input.daysOfActivityWindow // Treat as fully stalled when no activity
|
|
246
|
+
const value = deal.valueAmount ? parseFloat(String(deal.valueAmount)) : null
|
|
247
|
+
return {
|
|
248
|
+
id: deal.id,
|
|
249
|
+
title: deal.title,
|
|
250
|
+
value: Number.isFinite(value) ? value : null,
|
|
251
|
+
valueCurrency: deal.valueCurrency ?? null,
|
|
252
|
+
stage: deal.pipelineStage ?? deal.status ?? null,
|
|
253
|
+
status: deal.status ?? 'open',
|
|
254
|
+
daysSinceLastActivity,
|
|
255
|
+
primaryContact: primaryContactByDeal.get(deal.id) ?? null,
|
|
256
|
+
healthScore: Math.round(computeHealthScore(daysSinceLastActivity)),
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Sort most-at-risk first (lowest health score first, then by value desc)
|
|
261
|
+
analyzed.sort((a, b) => {
|
|
262
|
+
if (a.healthScore !== b.healthScore) return a.healthScore - b.healthScore
|
|
263
|
+
const aVal = a.value ?? 0
|
|
264
|
+
const bVal = b.value ?? 0
|
|
265
|
+
return bVal - aVal
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const truncated = analyzed.slice(0, input.limit)
|
|
269
|
+
const stalledCount = truncated.filter(
|
|
270
|
+
(d) => d.daysSinceLastActivity >= input.daysOfActivityWindow,
|
|
271
|
+
).length
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
deals: truncated,
|
|
275
|
+
totalAnalyzed: deals.length,
|
|
276
|
+
stalledCount,
|
|
277
|
+
windowDays: input.daysOfActivityWindow,
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export const dealAnalyzerAiTools: CustomersAiToolDefinition[] = [
|
|
283
|
+
analyzeDealsToolDefinition as unknown as CustomersAiToolDefinition,
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
export default dealAnalyzerAiTools
|
|
@@ -20,6 +20,7 @@ import dealsAiTools from './ai-tools/deals-pack'
|
|
|
20
20
|
import activitiesTasksAiTools from './ai-tools/activities-tasks-pack'
|
|
21
21
|
import addressesTagsAiTools from './ai-tools/addresses-tags-pack'
|
|
22
22
|
import settingsAiTools from './ai-tools/settings-pack'
|
|
23
|
+
import dealAnalyzerAiTools from './ai-tools/deal-analyzer-pack'
|
|
23
24
|
import type { CustomersAiToolDefinition } from './ai-tools/types'
|
|
24
25
|
|
|
25
26
|
export const aiTools: CustomersAiToolDefinition[] = [
|
|
@@ -29,6 +30,7 @@ export const aiTools: CustomersAiToolDefinition[] = [
|
|
|
29
30
|
...activitiesTasksAiTools,
|
|
30
31
|
...addressesTagsAiTools,
|
|
31
32
|
...settingsAiTools,
|
|
33
|
+
...dealAnalyzerAiTools,
|
|
32
34
|
]
|
|
33
35
|
|
|
34
36
|
export default aiTools
|
|
@@ -496,6 +496,16 @@ export default function CustomersCompaniesPage() {
|
|
|
496
496
|
{
|
|
497
497
|
fallbackErrorMessage: t('customers.companies.list.deleteError', 'Failed to delete company.'),
|
|
498
498
|
logTag: 'customers.companies.list',
|
|
499
|
+
progress: {
|
|
500
|
+
jobType: 'customers.companies.bulk_delete',
|
|
501
|
+
name: t('customers.companies.list.bulkDelete.progressName', 'Delete selected companies'),
|
|
502
|
+
description: t(
|
|
503
|
+
'customers.companies.list.bulkDelete.progressDescription',
|
|
504
|
+
'{count} companies selected for deletion',
|
|
505
|
+
{ count: selectedRows.length },
|
|
506
|
+
),
|
|
507
|
+
meta: { source: 'customers.companies.list' },
|
|
508
|
+
},
|
|
499
509
|
},
|
|
500
510
|
),
|
|
501
511
|
context: {
|
|
@@ -505,6 +505,16 @@ export default function CustomersDealsPage() {
|
|
|
505
505
|
{
|
|
506
506
|
fallbackErrorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
|
|
507
507
|
logTag: 'customers.deals.list',
|
|
508
|
+
progress: {
|
|
509
|
+
jobType: 'customers.deals.bulk_delete',
|
|
510
|
+
name: t('customers.deals.list.bulkDelete.progressName', 'Delete selected deals'),
|
|
511
|
+
description: t(
|
|
512
|
+
'customers.deals.list.bulkDelete.progressDescription',
|
|
513
|
+
'{count} deals selected for deletion',
|
|
514
|
+
{ count: selectedRows.length },
|
|
515
|
+
),
|
|
516
|
+
meta: { source: 'customers.deals.list' },
|
|
517
|
+
},
|
|
508
518
|
},
|
|
509
519
|
),
|
|
510
520
|
context: {
|
|
@@ -509,6 +509,16 @@ export default function CustomersPeoplePage() {
|
|
|
509
509
|
{
|
|
510
510
|
fallbackErrorMessage: t('customers.people.list.deleteError', 'Failed to delete person.'),
|
|
511
511
|
logTag: 'customers.people.list',
|
|
512
|
+
progress: {
|
|
513
|
+
jobType: 'customers.people.bulk_delete',
|
|
514
|
+
name: t('customers.people.list.bulkDelete.progressName', 'Delete selected people'),
|
|
515
|
+
description: t(
|
|
516
|
+
'customers.people.list.bulkDelete.progressDescription',
|
|
517
|
+
'{count} people selected for deletion',
|
|
518
|
+
{ count: selectedRows.length },
|
|
519
|
+
),
|
|
520
|
+
meta: { source: 'customers.people.list' },
|
|
521
|
+
},
|
|
512
522
|
},
|
|
513
523
|
),
|
|
514
524
|
context: {
|