@salesforce/afv-skills 1.62.0 → 1.64.0
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/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +253 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +796 -0
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +111 -0
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +1053 -0
- package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +21 -11
- package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +10 -0
- package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
- package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +44 -1
- package/skills/automation-sandbox-post-copy-configure/SKILL.md +132 -124
- package/skills/automation-sandbox-post-copy-configure/references/flow_activation_path.md +151 -0
- package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +55 -9
- package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +54 -107
- package/skills/consumer-goods-promotion-bo-api-deploy/assets/set-comment-value/payloads/copy.json +1 -1
- package/skills/consumer-goods-promotion-bo-api-deploy/references/conventions-and-payload-rules.md +82 -5
- package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +35 -2
- package/skills/consumer-goods-promotion-bo-api-deploy/references/preflight-and-seed-queries.md +149 -0
- package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +17 -1
- package/skills/dx-devops-conflict-resolve/SKILL.md +1 -5
- package/skills/dx-devops-pipeline-manage/SKILL.md +1 -5
- package/skills/dx-devops-project-manage/SKILL.md +1 -5
- package/skills/dx-devops-promote/SKILL.md +1 -5
- package/skills/dx-devops-request-status/SKILL.md +1 -5
- package/skills/dx-lifecycle-center/SKILL.md +120 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +175 -0
- package/skills/dx-lifecycle-center/references/connect-api.md +123 -0
- package/skills/dx-lifecycle-center/references/connect-org.md +184 -0
- package/skills/dx-lifecycle-center/references/deploy.md +153 -0
- package/skills/dx-lifecycle-center/references/error-handling.md +21 -0
- package/skills/dx-lifecycle-center/references/hub-org.md +37 -0
- package/skills/dx-lifecycle-center/references/list-orgs.md +99 -0
- package/skills/dx-lifecycle-center/references/request-body.md +75 -0
- package/skills/dx-lifecycle-center/references/troubleshooting.md +31 -0
- package/skills/dx-lifecycle-center/scripts/poll-deploy-status.sh +200 -0
- package/skills/dx-lifecycle-center/scripts/resolve-environment.sh +120 -0
- package/skills/dx-org-devhub-configure/SKILL.md +1 -3
- package/skills/dx-org-shape-manage/SKILL.md +1 -5
- package/skills/dx-org-snapshot-manage/SKILL.md +1 -3
- package/skills/education-cloud-academic-calendar-generate/SKILL.md +1 -3
- package/skills/education-cloud-course-catalog-migrate/SKILL.md +1 -3
- package/skills/education-cloud-domain-configure/SKILL.md +1 -3
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -3
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -5
- package/skills/experience-ui-bundle-2gp-deploy/SKILL.md +1 -3
- package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -5
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -3
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -3
- package/skills/insurance-brokerage-agency-billing-configure/SKILL.md +1 -3
- package/skills/marketing-message-test/SKILL.md +1 -3
- package/skills/nonprofit-fundraising-configure/SKILL.md +240 -0
- package/skills/nonprofit-fundraising-configure/references/gotchas.md +26 -0
- package/skills/nonprofit-fundraising-configure/references/mcp-invocation.md +678 -0
- package/skills/nonprofit-fundraising-configure/references/permission-targeting.md +35 -0
- package/skills/nonprofit-volunteer-management-model-get/SKILL.md +166 -0
- package/skills/nonprofit-volunteer-management-model-get/references/entities.md +315 -0
- package/skills/nonprofit-volunteer-management-model-get/references/relationships.md +175 -0
- package/skills/nonprofit-volunteer-management-model-get/references/themes.md +143 -0
- package/skills/nonprofit-volunteer-management-records-create/SKILL.md +121 -0
- package/skills/nonprofit-volunteer-management-records-create/references/field-guide.md +284 -0
- package/skills/nonprofit-volunteer-management-records-create/references/mcp-invocation.md +248 -0
- package/skills/nonprofit-volunteer-management-records-create/references/review-format.md +140 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/expand-series.py +632 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/render-review-table.py +419 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_expand_series.py +690 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_render_review_table.py +421 -0
- package/skills/nonprofit-volunteer-management-structure-generate/SKILL.md +119 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/configuration-decisions.md +159 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/discovery-interview.md +137 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/edge-cases.md +48 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/mcp-invocation.md +75 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/plan-output.md +141 -0
- package/skills/platform-custom-field-generate/SKILL.md +1 -0
- package/skills/platform-custom-field-generate/references/advanced-picklists.md +5 -5
- package/skills/platform-custom-metadata-type-generate/SKILL.md +3 -0
- package/skills/platform-custom-object-generate/SKILL.md +4 -0
- package/skills/platform-custom-setting-generate/SKILL.md +3 -0
- package/skills/platform-datamask-run/SKILL.md +1 -5
- package/skills/platform-permission-set-generate/SKILL.md +3 -0
- package/skills/platform-salesforce-connect-adapter-generate/SKILL.md +1 -3
- package/skills/platform-sandbox-configure/SKILL.md +1 -3
- package/skills/platform-value-set-generate/SKILL.md +3 -0
- package/skills/sales-call-scoring-configure/SKILL.md +1 -5
- package/skills/service-agentforce-channel-configure/SKILL.md +1 -3
- package/skills/service-agentforce-human-escalation-configure/SKILL.md +1 -3
- package/skills/service-catalog-template-coordinate/SKILL.md +1 -3
- package/skills/service-concierge-portal-generate/SKILL.md +1 -3
- package/skills/service-email-to-case-configure/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +1 -0
- package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +1 -0
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-uel-user-create/SKILL.md +1 -9
- package/skills/service-itsm-incident-mgmt-configure/SKILL.md +1 -5
- package/skills/service-itsm-incident-priority-configure/SKILL.md +1 -3
- package/skills/service-itsm-slack-itservice-configure/SKILL.md +1 -3
- package/skills/service-itsm-swarming-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-debug/SKILL.md +1 -3
- package/skills/service-itsm-teams-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itdesk-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itservice-configure/SKILL.md +1 -3
- package/skills/service-native-voice-recording-transcription-configure/SKILL.md +1 -3
- package/skills/service-omni-agent-users-create/SKILL.md +1 -3
- package/skills/service-omni-agent-work-sharing-configure/SKILL.md +1 -3
- package/skills/service-omni-attribute-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-base-settings-configure/SKILL.md +1 -3
- package/skills/service-omni-channel-inventory-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-limits-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +1 -3
- package/skills/service-omni-command-center-analyze/SKILL.md +1 -3
- package/skills/service-omni-command-center-configure/SKILL.md +1 -3
- package/skills/service-omni-permission-set-assign/SKILL.md +1 -3
- package/skills/service-omni-presence-status-deploy/SKILL.md +1 -3
- package/skills/service-omni-presence-user-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-members-assign/SKILL.md +1 -3
- package/skills/service-omni-queue-routing-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-routing-flow-deploy/SKILL.md +1 -3
- package/skills/service-omni-service-channel-configure/SKILL.md +1 -3
- package/skills/service-omni-sidebar-configure/SKILL.md +1 -3
- package/skills/service-omni-skills-based-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-supervisor-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-permset-assign/SKILL.md +1 -3
- package/skills/service-omni-supervisor-surface-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-users-create/SKILL.md +1 -3
- package/skills/service-omni-work-skill-routing-configure/SKILL.md +1 -3
- package/skills/field-service-setup-orchestrator-get/SKILL.md +0 -47
- package/skills/tableau-next-asset-create/README.md +0 -65
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SFAP-based replay pipeline for offline experimentation.
|
|
4
|
+
Reads STDM data, replays prompts via SFAP REST API with structured tool calling,
|
|
5
|
+
scores results, and generates a CSV report.
|
|
6
|
+
"""
|
|
7
|
+
import json, re, csv, os, sys, subprocess, time
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Config
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
SFAP_BASE_URL = os.environ.get("SFAP_BASE_URL", "https://test.api.salesforce.com/ai/gpt/v1")
|
|
13
|
+
SFAP_TOKEN_FILE = os.environ.get("SFAP_TOKEN_FILE", "")
|
|
14
|
+
CORE_TENANT_ID = os.environ.get("CORE_TENANT_ID", "")
|
|
15
|
+
REPLAY_MODEL = os.environ.get("REPLAY_MODEL", "llmgateway__OpenAIGPT41")
|
|
16
|
+
JUDGE_MODEL = os.environ.get("JUDGE_MODEL", REPLAY_MODEL)
|
|
17
|
+
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "replay_results_sfap.csv")
|
|
18
|
+
ORG_ID = os.environ.get("ORG_ID", "")
|
|
19
|
+
AGENT_API_NAME = os.environ.get("AGENT_API_NAME", "")
|
|
20
|
+
OUTPUT_JSON = os.environ.get("OUTPUT_JSON", "replay_results.json")
|
|
21
|
+
STDM_DATA_FILE = os.environ.get("STDM_DATA_FILE", "/tmp/details_result.json")
|
|
22
|
+
LLM_STEPS_FILES = os.environ.get("LLM_STEPS_FILES", "/tmp/llm_steps_1.json,/tmp/llm_steps_2.json").split(",")
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Dynamic tool extraction from STDM data
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Instead of hardcoded tools, we extract actual tool definitions from the
|
|
28
|
+
# original LLM responses in the STDM data. This handles both routing tools
|
|
29
|
+
# (go_to_*) and domain action tools (SearchProducts, AddToCart, etc.)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# Well-known tool schemas observed from agent responses. Keyed by tool name.
|
|
33
|
+
# These are built from observed argument patterns in STDM LLM responses.
|
|
34
|
+
KNOWN_TOOL_SCHEMAS = {
|
|
35
|
+
# --- Routing tools (topic router layer) ---
|
|
36
|
+
"go_to_product_discovery": {
|
|
37
|
+
"type": "function",
|
|
38
|
+
"function": {
|
|
39
|
+
"name": "go_to_product_discovery",
|
|
40
|
+
"description": "Route to product discovery topic for searching, browsing, product questions, recommendations.",
|
|
41
|
+
"parameters": {"type": "object", "properties": {}}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"go_to_cart_and_checkout": {
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": "go_to_cart_and_checkout",
|
|
48
|
+
"description": "Route to cart and checkout topic for adding items, viewing cart, checkout, payments.",
|
|
49
|
+
"parameters": {"type": "object", "properties": {}}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"go_to_store_help": {
|
|
53
|
+
"type": "function",
|
|
54
|
+
"function": {
|
|
55
|
+
"name": "go_to_store_help",
|
|
56
|
+
"description": "Route to store help topic for shipping, returns, store policies, greetings.",
|
|
57
|
+
"parameters": {"type": "object", "properties": {}}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"go_to_off_topic": {
|
|
61
|
+
"type": "function",
|
|
62
|
+
"function": {
|
|
63
|
+
"name": "go_to_off_topic",
|
|
64
|
+
"description": "Route to off-topic handler for queries unrelated to shopping.",
|
|
65
|
+
"parameters": {"type": "object", "properties": {}}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
# --- Domain action tools (sub-topic agents) ---
|
|
69
|
+
"SearchProducts": {
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {
|
|
72
|
+
"name": "SearchProducts",
|
|
73
|
+
"description": "Search the product catalog with optional filters.",
|
|
74
|
+
"parameters": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"searchQuery": {"type": "string", "description": "Search terms or keywords"},
|
|
78
|
+
"category": {"type": "string", "description": "Product category filter"},
|
|
79
|
+
"color": {"type": "string", "description": "Color filter"},
|
|
80
|
+
"department": {"type": "string", "description": "Department filter (e.g. Men, Women, Unisex)"},
|
|
81
|
+
"maxPrice": {"type": "string", "description": "Maximum price filter"},
|
|
82
|
+
"minPrice": {"type": "string", "description": "Minimum price filter"},
|
|
83
|
+
"sortBy": {"type": "string", "description": "Sort order (e.g. relevance, price_asc, price_desc, rating)"}
|
|
84
|
+
},
|
|
85
|
+
"required": []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"GetProductDetails": {
|
|
90
|
+
"type": "function",
|
|
91
|
+
"function": {
|
|
92
|
+
"name": "GetProductDetails",
|
|
93
|
+
"description": "Get detailed information about a specific product.",
|
|
94
|
+
"parameters": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"productCode": {"type": "string", "description": "The product code (e.g. SHOE-M-088)"}
|
|
98
|
+
},
|
|
99
|
+
"required": ["productCode"]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"GetRecommendations": {
|
|
104
|
+
"type": "function",
|
|
105
|
+
"function": {
|
|
106
|
+
"name": "GetRecommendations",
|
|
107
|
+
"description": "Get product recommendations based on a product.",
|
|
108
|
+
"parameters": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"productCode": {"type": "string", "description": "Product code to base recommendations on"}
|
|
112
|
+
},
|
|
113
|
+
"required": ["productCode"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"AddToCart": {
|
|
118
|
+
"type": "function",
|
|
119
|
+
"function": {
|
|
120
|
+
"name": "AddToCart",
|
|
121
|
+
"description": "Add a product to the shopping cart.",
|
|
122
|
+
"parameters": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"productCode": {"type": "string", "description": "Product code to add"},
|
|
126
|
+
"quantity": {"type": "string", "description": "Quantity to add"},
|
|
127
|
+
"size": {"type": "string", "description": "Size selection"},
|
|
128
|
+
"color": {"type": "string", "description": "Color selection"}
|
|
129
|
+
},
|
|
130
|
+
"required": ["productCode", "quantity"]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"ViewCart": {
|
|
135
|
+
"type": "function",
|
|
136
|
+
"function": {
|
|
137
|
+
"name": "ViewCart",
|
|
138
|
+
"description": "View the current shopping cart contents.",
|
|
139
|
+
"parameters": {"type": "object", "properties": {}}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"UpdateCart": {
|
|
143
|
+
"type": "function",
|
|
144
|
+
"function": {
|
|
145
|
+
"name": "UpdateCart",
|
|
146
|
+
"description": "Update cart item quantity, size, or color. Set quantity to 0 to remove.",
|
|
147
|
+
"parameters": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"properties": {
|
|
150
|
+
"productCode": {"type": "string", "description": "Product code to update (or 'all' to clear cart)"},
|
|
151
|
+
"quantity": {"type": "string", "description": "New quantity (0 to remove)"},
|
|
152
|
+
"size": {"type": "string", "description": "New size"},
|
|
153
|
+
"color": {"type": "string", "description": "New color"}
|
|
154
|
+
},
|
|
155
|
+
"required": ["productCode", "quantity"]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"Checkout": {
|
|
160
|
+
"type": "function",
|
|
161
|
+
"function": {
|
|
162
|
+
"name": "Checkout",
|
|
163
|
+
"description": "Place an order with the current cart.",
|
|
164
|
+
"parameters": {
|
|
165
|
+
"type": "object",
|
|
166
|
+
"properties": {
|
|
167
|
+
"shippingMethod": {"type": "string", "description": "Shipping method (Standard or Express)"},
|
|
168
|
+
"shopperName": {"type": "string", "description": "Name for the order"}
|
|
169
|
+
},
|
|
170
|
+
"required": ["shippingMethod"]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
# --- Generic CRM tools (fallback for non-shopper agents) ---
|
|
175
|
+
"QueryRecords": {
|
|
176
|
+
"type": "function",
|
|
177
|
+
"function": {
|
|
178
|
+
"name": "QueryRecords",
|
|
179
|
+
"description": "Query records using natural language.",
|
|
180
|
+
"parameters": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"query": {"type": "string", "description": "Natural language query"}
|
|
184
|
+
},
|
|
185
|
+
"required": ["query"]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"IdentifyRecordByName": {
|
|
190
|
+
"type": "function",
|
|
191
|
+
"function": {
|
|
192
|
+
"name": "IdentifyRecordByName",
|
|
193
|
+
"description": "Identify a record by its name to get the record ID.",
|
|
194
|
+
"parameters": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"name": {"type": "string", "description": "The name of the record"},
|
|
198
|
+
"objectType": {"type": "string", "description": "The Salesforce object type"}
|
|
199
|
+
},
|
|
200
|
+
"required": ["name"]
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
"GetRecordDetails": {
|
|
205
|
+
"type": "function",
|
|
206
|
+
"function": {
|
|
207
|
+
"name": "GetRecordDetails",
|
|
208
|
+
"description": "Get detailed information about a specific record by its ID.",
|
|
209
|
+
"parameters": {
|
|
210
|
+
"type": "object",
|
|
211
|
+
"properties": {
|
|
212
|
+
"recordId": {"type": "string", "description": "The Salesforce record ID"}
|
|
213
|
+
},
|
|
214
|
+
"required": ["recordId"]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ROUTING_TOOLS = ['go_to_product_discovery', 'go_to_cart_and_checkout', 'go_to_store_help', 'go_to_off_topic']
|
|
221
|
+
ACTION_TOOLS_SHOPPER = ['SearchProducts', 'GetProductDetails', 'GetRecommendations',
|
|
222
|
+
'AddToCart', 'ViewCart', 'UpdateCart', 'Checkout']
|
|
223
|
+
GENERIC_CRM_TOOLS = ['QueryRecords', 'IdentifyRecordByName', 'GetRecordDetails']
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_routing_tools(tools):
|
|
227
|
+
"""True when ALL tools in the list are routing functions (go_to_* / __end_session_action__).
|
|
228
|
+
Action steps may include a single go_to_* for cross-topic transfer alongside domain tools,
|
|
229
|
+
so checking 'any' would mis-classify them."""
|
|
230
|
+
if not tools:
|
|
231
|
+
return False
|
|
232
|
+
for t in tools:
|
|
233
|
+
name = t.get('function', {}).get('name', '')
|
|
234
|
+
if not (name.startswith('go_to_') or name == '__end_session_action__'):
|
|
235
|
+
return False
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_tool_call_from_step_output(step):
|
|
240
|
+
"""Extract the first tool invocation from a step's gen_ai.output.messages."""
|
|
241
|
+
step_output = step.get('output', '') or ''
|
|
242
|
+
try:
|
|
243
|
+
out_json = json.loads(step_output) if isinstance(step_output, str) else step_output
|
|
244
|
+
out_msgs = out_json.get('gen_ai.output.messages', [])
|
|
245
|
+
for om in out_msgs:
|
|
246
|
+
for inv in om.get('tool_invocations', []):
|
|
247
|
+
func = inv.get('function', {})
|
|
248
|
+
if func.get('name'):
|
|
249
|
+
args = func.get('arguments', '')
|
|
250
|
+
if isinstance(args, dict):
|
|
251
|
+
args = json.dumps(args)
|
|
252
|
+
return func['name'], args
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return None, None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def sanitize_tools_for_sfap(tools):
|
|
259
|
+
"""Clean STDM tool schemas to be compatible with SFAP's OpenAI-format endpoint.
|
|
260
|
+
Removes additionalProperties and empty required arrays that cause 400 errors."""
|
|
261
|
+
sanitized = []
|
|
262
|
+
for tool in tools:
|
|
263
|
+
tool = json.loads(json.dumps(tool)) # deep copy
|
|
264
|
+
func = tool.get('function', {})
|
|
265
|
+
params = func.get('parameters', {})
|
|
266
|
+
params.pop('additionalProperties', None)
|
|
267
|
+
if 'required' in params and not params['required']:
|
|
268
|
+
del params['required']
|
|
269
|
+
sanitized.append(tool)
|
|
270
|
+
return sanitized
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def build_tools_for_step(original_tool_name, prompt_text):
|
|
274
|
+
"""Select the right tool set based on the original tool call and prompt content."""
|
|
275
|
+
# If original called a routing tool, offer routing tools
|
|
276
|
+
if original_tool_name and original_tool_name.startswith('go_to_'):
|
|
277
|
+
return [KNOWN_TOOL_SCHEMAS[t] for t in ROUTING_TOOLS if t in KNOWN_TOOL_SCHEMAS]
|
|
278
|
+
|
|
279
|
+
# If original called a shopper action tool, offer shopper tools
|
|
280
|
+
if original_tool_name and original_tool_name in ACTION_TOOLS_SHOPPER:
|
|
281
|
+
return [KNOWN_TOOL_SCHEMAS[t] for t in ACTION_TOOLS_SHOPPER if t in KNOWN_TOOL_SCHEMAS]
|
|
282
|
+
|
|
283
|
+
# Detect from prompt content
|
|
284
|
+
if prompt_text:
|
|
285
|
+
if 'go_to_product_discovery' in prompt_text or 'go_to_cart' in prompt_text:
|
|
286
|
+
return [KNOWN_TOOL_SCHEMAS[t] for t in ROUTING_TOOLS if t in KNOWN_TOOL_SCHEMAS]
|
|
287
|
+
if 'SearchProducts' in prompt_text or 'AddToCart' in prompt_text:
|
|
288
|
+
return [KNOWN_TOOL_SCHEMAS[t] for t in ACTION_TOOLS_SHOPPER if t in KNOWN_TOOL_SCHEMAS]
|
|
289
|
+
|
|
290
|
+
# Fallback to generic CRM tools
|
|
291
|
+
return [KNOWN_TOOL_SCHEMAS[t] for t in GENERIC_CRM_TOOLS if t in KNOWN_TOOL_SCHEMAS]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def extract_tools_from_llm_responses(llm_step_map):
|
|
295
|
+
"""Scan all LLM responses to discover the actual tool names used by the agent."""
|
|
296
|
+
seen = set()
|
|
297
|
+
for step in llm_step_map.values():
|
|
298
|
+
resp_text = step.get('llm_response', '') or ''
|
|
299
|
+
if not resp_text:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
resp = json.loads(resp_text)
|
|
303
|
+
for inv in resp.get('toolInvocations', []):
|
|
304
|
+
name = inv.get('function', {}).get('name', '')
|
|
305
|
+
if name:
|
|
306
|
+
seen.add(name)
|
|
307
|
+
except:
|
|
308
|
+
pass
|
|
309
|
+
return seen
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Legacy alias for backward compatibility
|
|
313
|
+
AGENT_TOOLS = [KNOWN_TOOL_SCHEMAS[t] for t in GENERIC_CRM_TOOLS if t in KNOWN_TOOL_SCHEMAS]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def read_token():
|
|
317
|
+
if SFAP_TOKEN_FILE and os.path.exists(SFAP_TOKEN_FILE):
|
|
318
|
+
with open(SFAP_TOKEN_FILE) as f:
|
|
319
|
+
return f.read().strip()
|
|
320
|
+
raise RuntimeError("SFAP_TOKEN_FILE not set or file not found")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_prompt_to_messages(prompt_text):
|
|
324
|
+
"""Parse role-based prompt text into OpenAI-style messages array.
|
|
325
|
+
|
|
326
|
+
Keeps system messages in their original position — production keeps
|
|
327
|
+
subagent instructions at the end of the conversation, not merged to the top.
|
|
328
|
+
Merges consecutive same-role messages to keep alternation valid.
|
|
329
|
+
Appends a user "Continue." if conversation ends on assistant."""
|
|
330
|
+
messages = []
|
|
331
|
+
current_role = None
|
|
332
|
+
current_content = []
|
|
333
|
+
|
|
334
|
+
for line in prompt_text.split('\n'):
|
|
335
|
+
stripped = line.strip()
|
|
336
|
+
if stripped in ('role: system', 'role: user', 'role: assistant'):
|
|
337
|
+
if current_role and current_content:
|
|
338
|
+
content = '\n'.join(current_content).strip()
|
|
339
|
+
if content:
|
|
340
|
+
messages.append({"role": current_role, "content": content})
|
|
341
|
+
current_role = stripped.replace('role: ', '')
|
|
342
|
+
current_content = []
|
|
343
|
+
else:
|
|
344
|
+
if current_role:
|
|
345
|
+
current_content.append(line)
|
|
346
|
+
|
|
347
|
+
if current_role and current_content:
|
|
348
|
+
content = '\n'.join(current_content).strip()
|
|
349
|
+
if content:
|
|
350
|
+
messages.append({"role": current_role, "content": content})
|
|
351
|
+
|
|
352
|
+
# Merge consecutive same-role messages (keeps system at original position)
|
|
353
|
+
merged = []
|
|
354
|
+
for m in messages:
|
|
355
|
+
if merged and merged[-1]['role'] == m['role']:
|
|
356
|
+
merged[-1]['content'] = f"{merged[-1]['content']}\n{m['content']}".strip()
|
|
357
|
+
else:
|
|
358
|
+
merged.append(m)
|
|
359
|
+
|
|
360
|
+
if merged and merged[-1]['role'] == 'assistant':
|
|
361
|
+
merged.append({"role": "user", "content": "Continue."})
|
|
362
|
+
return merged
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def convert_stdm_messages_for_sfap(stdm_messages):
|
|
366
|
+
"""Convert STDM gen_ai.input.messages to SFAP-compatible OpenAI format.
|
|
367
|
+
|
|
368
|
+
Matches the temporal version's approach (src/replay/traces.py:_messages):
|
|
369
|
+
- Folds tool-role messages into the previous message as text
|
|
370
|
+
- Converts assistant tool_invocations to text (keeps content)
|
|
371
|
+
- Merges consecutive same-role messages
|
|
372
|
+
- Keeps system messages in their original position (does NOT merge trailing
|
|
373
|
+
system into the first — production keeps subagent instructions at the end)
|
|
374
|
+
- Appends a user "Continue." if conversation ends on assistant
|
|
375
|
+
"""
|
|
376
|
+
raw = []
|
|
377
|
+
for m in stdm_messages:
|
|
378
|
+
role = m.get('role', '')
|
|
379
|
+
content = m.get('content', '') or ''
|
|
380
|
+
tool_invocations = m.get('tool_invocations', [])
|
|
381
|
+
if role == 'assistant' and tool_invocations:
|
|
382
|
+
parts = []
|
|
383
|
+
if content.strip():
|
|
384
|
+
parts.append(content)
|
|
385
|
+
for inv in tool_invocations:
|
|
386
|
+
func = inv.get('function', {})
|
|
387
|
+
name = func.get('name', '')
|
|
388
|
+
args = func.get('arguments', '')
|
|
389
|
+
if isinstance(args, dict):
|
|
390
|
+
args = json.dumps(args)
|
|
391
|
+
parts.append(f"[tool_call] {name}({args})")
|
|
392
|
+
raw.append({"role": "assistant", "content": "\n".join(parts)})
|
|
393
|
+
else:
|
|
394
|
+
raw.append({"role": role, "content": content})
|
|
395
|
+
|
|
396
|
+
out = []
|
|
397
|
+
for m in raw:
|
|
398
|
+
role, content = m["role"], m["content"]
|
|
399
|
+
if role == "tool":
|
|
400
|
+
text = f"[tool result] {content}".strip()
|
|
401
|
+
if out:
|
|
402
|
+
out[-1]["content"] = f"{out[-1]['content']}\n{text}".strip()
|
|
403
|
+
else:
|
|
404
|
+
out.append({"role": "assistant", "content": text})
|
|
405
|
+
continue
|
|
406
|
+
if not content.strip():
|
|
407
|
+
continue
|
|
408
|
+
if out and out[-1]["role"] == role:
|
|
409
|
+
out[-1]["content"] = f"{out[-1]['content']}\n{content}".strip()
|
|
410
|
+
else:
|
|
411
|
+
out.append({"role": role, "content": content})
|
|
412
|
+
|
|
413
|
+
if out and out[-1]["role"] == "assistant":
|
|
414
|
+
out.append({"role": "user", "content": "Continue."})
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def prompt_has_tools(prompt_text):
|
|
419
|
+
"""Check if this prompt includes tool/function calling instructions."""
|
|
420
|
+
indicators = [
|
|
421
|
+
'data retrieval functions',
|
|
422
|
+
'QueryRecords',
|
|
423
|
+
'IdentifyRecordByName',
|
|
424
|
+
'GetRecordDetails',
|
|
425
|
+
'TOOL USAGE PROTOCOL',
|
|
426
|
+
'go_to_product_discovery',
|
|
427
|
+
'go_to_cart_and_checkout',
|
|
428
|
+
'go_to_store_help',
|
|
429
|
+
'SearchProducts',
|
|
430
|
+
'AddToCart',
|
|
431
|
+
'Checkout',
|
|
432
|
+
]
|
|
433
|
+
return any(ind in prompt_text for ind in indicators)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def call_sfap(messages, model, tools=None, max_tokens=2048):
|
|
437
|
+
"""Call SFAP chat generations endpoint."""
|
|
438
|
+
token = read_token()
|
|
439
|
+
url = f"{SFAP_BASE_URL}/chat/generations"
|
|
440
|
+
|
|
441
|
+
payload = {
|
|
442
|
+
"model": model,
|
|
443
|
+
"messages": messages,
|
|
444
|
+
"max_tokens": max_tokens,
|
|
445
|
+
}
|
|
446
|
+
if tools:
|
|
447
|
+
payload["tools"] = tools
|
|
448
|
+
payload["tool_choice"] = "required"
|
|
449
|
+
|
|
450
|
+
payload_json = json.dumps(payload)
|
|
451
|
+
|
|
452
|
+
cmd = [
|
|
453
|
+
"curl", "-sS", "-w", "\n__HTTP_STATUS:%{http_code}",
|
|
454
|
+
url,
|
|
455
|
+
"-H", f"Authorization: Bearer {token}",
|
|
456
|
+
"-H", "Content-Type: application/json",
|
|
457
|
+
"-H", "x-sfdc-app-context: EinsteinGPT",
|
|
458
|
+
"-H", "x-client-feature-id: AgentBacktesting",
|
|
459
|
+
"-H", f"x-sfdc-core-tenant-id: {CORE_TENANT_ID}",
|
|
460
|
+
"-d", payload_json,
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
464
|
+
output = result.stdout
|
|
465
|
+
|
|
466
|
+
# Split response body and HTTP status
|
|
467
|
+
parts = output.rsplit('\n__HTTP_STATUS:', 1)
|
|
468
|
+
body = parts[0] if parts else output
|
|
469
|
+
status = int(parts[1].strip()) if len(parts) > 1 else 0
|
|
470
|
+
|
|
471
|
+
if status != 200:
|
|
472
|
+
return {"error": f"HTTP {status}", "body": body[:500]}
|
|
473
|
+
|
|
474
|
+
return json.loads(body)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def extract_tool_call(sfap_response):
|
|
478
|
+
"""Extract tool invocation from SFAP response."""
|
|
479
|
+
try:
|
|
480
|
+
gens = sfap_response.get("generation_details", {}).get("generations", [])
|
|
481
|
+
if not gens:
|
|
482
|
+
return None, None, None
|
|
483
|
+
|
|
484
|
+
gen = gens[0]
|
|
485
|
+
content = gen.get("content", "")
|
|
486
|
+
invocations = gen.get("tool_invocations") or []
|
|
487
|
+
|
|
488
|
+
if invocations:
|
|
489
|
+
inv = invocations[0]
|
|
490
|
+
func = inv.get("function", {})
|
|
491
|
+
return func.get("name"), func.get("arguments", ""), content
|
|
492
|
+
return None, None, content
|
|
493
|
+
except Exception as e:
|
|
494
|
+
return None, None, str(e)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def extract_original_tool_call(llm_response_text):
|
|
498
|
+
"""Extract tool invocation from the original STDM LLM response."""
|
|
499
|
+
if not llm_response_text:
|
|
500
|
+
return None, None, llm_response_text
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
resp = json.loads(llm_response_text)
|
|
504
|
+
invocations = resp.get("toolInvocations", [])
|
|
505
|
+
if invocations:
|
|
506
|
+
func = invocations[0].get("function", {})
|
|
507
|
+
return func.get("name"), func.get("arguments", ""), resp.get("content", "")
|
|
508
|
+
return None, None, resp.get("content", llm_response_text)
|
|
509
|
+
except:
|
|
510
|
+
return None, None, llm_response_text
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def judge_text(actual, expected, question, model):
|
|
514
|
+
"""LLM-as-judge for text responses via SFAP."""
|
|
515
|
+
prompt = f"""You are a scoring judge. Compare the predicted response against the reference response.
|
|
516
|
+
|
|
517
|
+
Output ONLY valid JSON with "reasoning" and "score" fields.
|
|
518
|
+
|
|
519
|
+
**Scoring Rubric**
|
|
520
|
+
1. score=1 -- the predicted response covers key information of the reference response OR it takes a different but reasonable action
|
|
521
|
+
2. score=0.66 -- the predicted response covers a good amount of information OR it takes a somewhat reasonable action
|
|
522
|
+
3. score=0.33 -- the predicted response covers little information OR it takes a less reasonable action
|
|
523
|
+
4. score=0 -- the predicted response covers no information OR it takes a completely unreasonable action
|
|
524
|
+
|
|
525
|
+
**Guidelines**
|
|
526
|
+
1. The reference answer is a reference, not strict ground truth. Be tolerant to different but reasonable answers.
|
|
527
|
+
2. It is fine if the predicted response sounds like an intermediate step rather than a direct answer.
|
|
528
|
+
3. The use case context is Salesforce Agentforce assisting customers.
|
|
529
|
+
4. Output valid JSON only.
|
|
530
|
+
|
|
531
|
+
[question]: {question}
|
|
532
|
+
[expected text]: {expected}
|
|
533
|
+
[predicted text]: {actual}
|
|
534
|
+
|
|
535
|
+
Output:
|
|
536
|
+
"""
|
|
537
|
+
messages = [{"role": "user", "content": prompt}]
|
|
538
|
+
resp = call_sfap(messages, model, max_tokens=512)
|
|
539
|
+
if "error" in resp:
|
|
540
|
+
return None, f"Judge error: {resp['error']}"
|
|
541
|
+
|
|
542
|
+
gens = resp.get("generation_details", {}).get("generations", [])
|
|
543
|
+
if not gens:
|
|
544
|
+
return None, "No judge response"
|
|
545
|
+
|
|
546
|
+
text = gens[0].get("content", "")
|
|
547
|
+
try:
|
|
548
|
+
# Extract JSON from response
|
|
549
|
+
json_match = re.search(r'\{[^{}]*"score"[^{}]*\}', text, re.DOTALL)
|
|
550
|
+
if json_match:
|
|
551
|
+
parsed = json.loads(json_match.group())
|
|
552
|
+
return float(parsed.get("score", 0)), parsed.get("reasoning", "")
|
|
553
|
+
except:
|
|
554
|
+
pass
|
|
555
|
+
return None, f"Could not parse judge response: {text[:200]}"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def judge_args(actual_args, expected_args, func_name, question, model):
|
|
559
|
+
"""LLM-as-judge for function arguments via SFAP."""
|
|
560
|
+
prompt = f"""You are a scoring judge. Compare function call arguments.
|
|
561
|
+
|
|
562
|
+
Output ONLY valid JSON with "reasoning" and "score" fields.
|
|
563
|
+
|
|
564
|
+
**Scoring Rubric**
|
|
565
|
+
1. score=1 -- the arguments are semantically equivalent (same values, possibly different format)
|
|
566
|
+
2. score=0.66 -- most arguments match, minor differences in non-critical fields
|
|
567
|
+
3. score=0.33 -- some arguments match but key fields differ
|
|
568
|
+
4. score=0 -- arguments are completely different or wrong
|
|
569
|
+
|
|
570
|
+
**Guidelines**
|
|
571
|
+
1. Compare semantic meaning, not exact string match.
|
|
572
|
+
2. Order of JSON keys does not matter.
|
|
573
|
+
3. The use case context is Salesforce Agentforce.
|
|
574
|
+
4. Output valid JSON only.
|
|
575
|
+
|
|
576
|
+
[user question]: {question}
|
|
577
|
+
[function name]: {func_name}
|
|
578
|
+
[expected arguments]: {expected_args}
|
|
579
|
+
[predicted arguments]: {actual_args}
|
|
580
|
+
|
|
581
|
+
Output:
|
|
582
|
+
"""
|
|
583
|
+
messages = [{"role": "user", "content": prompt}]
|
|
584
|
+
resp = call_sfap(messages, model, max_tokens=512)
|
|
585
|
+
if "error" in resp:
|
|
586
|
+
return None, f"Judge error: {resp['error']}"
|
|
587
|
+
|
|
588
|
+
gens = resp.get("generation_details", {}).get("generations", [])
|
|
589
|
+
if not gens:
|
|
590
|
+
return None, "No judge response"
|
|
591
|
+
|
|
592
|
+
text = gens[0].get("content", "")
|
|
593
|
+
try:
|
|
594
|
+
json_match = re.search(r'\{[^{}]*"score"[^{}]*\}', text, re.DOTALL)
|
|
595
|
+
if json_match:
|
|
596
|
+
parsed = json.loads(json_match.group())
|
|
597
|
+
return float(parsed.get("score", 0)), parsed.get("reasoning", "")
|
|
598
|
+
except:
|
|
599
|
+
pass
|
|
600
|
+
return None, f"Could not parse judge response: {text[:200]}"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
# Enriched-output helpers (for HTML report)
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
TOOL_GROUPS = {
|
|
607
|
+
'record_lookup': {'IdentifyRecordByName', 'GetRecordDetails', 'QueryRecords'},
|
|
608
|
+
'record_modify': {'UpdateRecordFields', 'ExtractRecordFieldsAndValuesFromUserInput'},
|
|
609
|
+
'activity': {'GetActivitiesTimeline', 'GetActivityDetails'},
|
|
610
|
+
'routing_discovery': {'go_to_product_discovery'},
|
|
611
|
+
'routing_cart': {'go_to_cart_and_checkout'},
|
|
612
|
+
'routing_help': {'go_to_store_help'},
|
|
613
|
+
'routing_off': {'go_to_off_topic'},
|
|
614
|
+
'shopper_search': {'SearchProducts', 'GetProductDetails', 'GetRecommendations'},
|
|
615
|
+
'shopper_cart': {'AddToCart', 'ViewCart', 'UpdateCart', 'Checkout'},
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def names_match_lenient(golden, predicted):
|
|
620
|
+
if not golden or not predicted:
|
|
621
|
+
return False
|
|
622
|
+
if golden.lower() == predicted.lower():
|
|
623
|
+
return True
|
|
624
|
+
for group in TOOL_GROUPS.values():
|
|
625
|
+
if golden in group and predicted in group:
|
|
626
|
+
return True
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def build_structured_answer(tool_name, tool_args, text_content):
|
|
631
|
+
answer = {"content": text_content or ""}
|
|
632
|
+
if tool_name:
|
|
633
|
+
args = tool_args
|
|
634
|
+
if isinstance(args, str):
|
|
635
|
+
try:
|
|
636
|
+
args = json.loads(args)
|
|
637
|
+
except Exception:
|
|
638
|
+
args = {"raw": args} if args else {}
|
|
639
|
+
answer["tool_calls"] = [{"name": tool_name, "arguments": args or {}}]
|
|
640
|
+
return answer
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def classify_golden_type(tool_name):
|
|
644
|
+
if not tool_name:
|
|
645
|
+
return "action"
|
|
646
|
+
lower = tool_name.lower()
|
|
647
|
+
if any(p in lower for p in ('route_to', 'go_to', 'transfer_to')):
|
|
648
|
+
return "topic"
|
|
649
|
+
return "action"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def build_report_json(results, csv_rows):
|
|
653
|
+
rows = []
|
|
654
|
+
recs = {}
|
|
655
|
+
for i, (r, cr) in enumerate(zip(results, csv_rows)):
|
|
656
|
+
golden = build_structured_answer(
|
|
657
|
+
r.get('original_tool_name'), r.get('original_tool_args'), r.get('original_text'))
|
|
658
|
+
predicted = build_structured_answer(
|
|
659
|
+
r.get('replayed_tool_name'), r.get('replayed_tool_args'), r.get('replayed_text'))
|
|
660
|
+
|
|
661
|
+
g_name = r.get('original_tool_name')
|
|
662
|
+
p_name = r.get('replayed_tool_name')
|
|
663
|
+
name_match = (g_name == p_name) if (g_name and p_name) else (not g_name and not p_name)
|
|
664
|
+
lenient = names_match_lenient(g_name, p_name) or name_match
|
|
665
|
+
|
|
666
|
+
args_score = cr.get('function_args_score')
|
|
667
|
+
args_is_num = isinstance(args_score, (int, float))
|
|
668
|
+
args_match = args_is_num and args_score >= 0.8
|
|
669
|
+
args_lenient = args_is_num and args_score >= 0.5
|
|
670
|
+
|
|
671
|
+
tool_alt = ""
|
|
672
|
+
scoring_err = r.get('replay_error') or ""
|
|
673
|
+
if g_name and not p_name:
|
|
674
|
+
scoring_err = f"Expected tool call ({g_name}), got text response"
|
|
675
|
+
elif not g_name and p_name:
|
|
676
|
+
scoring_err = f"Expected text, got tool call ({p_name})"
|
|
677
|
+
elif g_name and p_name and not name_match and lenient:
|
|
678
|
+
tool_alt = f"Used {p_name} instead of {g_name} (functionally similar)"
|
|
679
|
+
|
|
680
|
+
rows.append({
|
|
681
|
+
'SessionRowIndex': i,
|
|
682
|
+
'AiAgentSessionId': r['session_id'],
|
|
683
|
+
'GoldenType': r.get('golden_type', classify_golden_type(g_name)),
|
|
684
|
+
'Utterance': r['user_utterance'],
|
|
685
|
+
'GoldenAnswer': json.dumps(golden),
|
|
686
|
+
'PredictedAnswer': json.dumps(predicted),
|
|
687
|
+
'FunctionNameAgreement': name_match,
|
|
688
|
+
'FunctionNameAgreementLenient': lenient,
|
|
689
|
+
'PassStrict': name_match and args_match,
|
|
690
|
+
'FunctionArgsAgreement': args_match,
|
|
691
|
+
'FunctionArgsAgreementLenient': args_lenient,
|
|
692
|
+
'FunctionArgsScore': args_score if args_is_num else None,
|
|
693
|
+
'FunctionArgsScoreReason': r.get('args_reasoning', ''),
|
|
694
|
+
'ToolAltReason': tool_alt,
|
|
695
|
+
'SchemaViolationNotes': '',
|
|
696
|
+
'ScoringError': scoring_err,
|
|
697
|
+
'BaselineModel': cr.get('original_model', ''),
|
|
698
|
+
'ReplayModel': cr.get('replay_model', REPLAY_MODEL),
|
|
699
|
+
'OrgId': ORG_ID,
|
|
700
|
+
'AgentApiName': AGENT_API_NAME,
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
recs[str(i)] = {
|
|
704
|
+
'messages': r.get('replay_messages', []),
|
|
705
|
+
'tools': [t['function']['name'] for t in (r.get('replay_tools') or [])],
|
|
706
|
+
'session_id': r['session_id'],
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
'metadata': {
|
|
711
|
+
'baseline_model': 'openai/gpt-4.1',
|
|
712
|
+
'replay_model': REPLAY_MODEL,
|
|
713
|
+
'org_id': ORG_ID,
|
|
714
|
+
'agent_api_name': AGENT_API_NAME,
|
|
715
|
+
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
716
|
+
'total_rows': len(rows),
|
|
717
|
+
},
|
|
718
|
+
'rows': rows,
|
|
719
|
+
'recs': recs,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
# Main pipeline
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
def main():
|
|
727
|
+
print("=" * 60)
|
|
728
|
+
print("SFAP Replay Pipeline")
|
|
729
|
+
print("=" * 60)
|
|
730
|
+
|
|
731
|
+
# 1. Load STDM conversation data
|
|
732
|
+
print(f"\n[Phase 1] Loading STDM data from {STDM_DATA_FILE}...")
|
|
733
|
+
with open(STDM_DATA_FILE) as f:
|
|
734
|
+
logs = json.load(f)['result']['logs']
|
|
735
|
+
idx = logs.find('DEBUG|STDM_RESULT:')
|
|
736
|
+
convos = json.loads(logs[idx + len('DEBUG|STDM_RESULT:'):].split('\n')[0].strip())
|
|
737
|
+
print(f" Loaded {len(convos)} conversations")
|
|
738
|
+
|
|
739
|
+
# Load LLM step details
|
|
740
|
+
all_llm_steps = []
|
|
741
|
+
for f_path in LLM_STEPS_FILES:
|
|
742
|
+
f_path = f_path.strip()
|
|
743
|
+
try:
|
|
744
|
+
all_llm_steps.extend(json.load(open(f_path)))
|
|
745
|
+
except:
|
|
746
|
+
pass
|
|
747
|
+
llm_step_map = {s['step_id']: s for s in all_llm_steps}
|
|
748
|
+
print(f" Loaded {len(all_llm_steps)} LLM step details")
|
|
749
|
+
|
|
750
|
+
# 2. Build replay targets — both router (topic) and action steps per turn
|
|
751
|
+
print("\n[Phase 1.5] Building replay targets...")
|
|
752
|
+
replay_targets = []
|
|
753
|
+
|
|
754
|
+
for convo in convos:
|
|
755
|
+
session_id = convo['session_id']
|
|
756
|
+
for turn_idx, turn in enumerate(convo.get('turns', [])):
|
|
757
|
+
user_msg = None
|
|
758
|
+
output_msg = None
|
|
759
|
+
for msg in turn.get('messages', []):
|
|
760
|
+
if msg.get('message_type') == 'Input' and msg.get('text'):
|
|
761
|
+
user_msg = msg['text'].strip()
|
|
762
|
+
if msg.get('message_type') == 'Output' and msg.get('text'):
|
|
763
|
+
output_msg = msg['text'].strip()
|
|
764
|
+
|
|
765
|
+
if not user_msg:
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
topic = turn.get('topic')
|
|
769
|
+
|
|
770
|
+
# Collect all LLM steps with tools, separating router vs action
|
|
771
|
+
router_step = None
|
|
772
|
+
action_step = None
|
|
773
|
+
|
|
774
|
+
for step in turn.get('steps', []):
|
|
775
|
+
if step.get('step_type') != 'LLM_STEP' or not step.get('input'):
|
|
776
|
+
continue
|
|
777
|
+
try:
|
|
778
|
+
step_input = json.loads(step['input'])
|
|
779
|
+
except Exception:
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
tools_from_input = step_input.get('gen_ai.request.tools', [])
|
|
783
|
+
if not tools_from_input:
|
|
784
|
+
continue
|
|
785
|
+
|
|
786
|
+
if _is_routing_tools(tools_from_input):
|
|
787
|
+
if not router_step:
|
|
788
|
+
router_step = (step, step_input, tools_from_input)
|
|
789
|
+
else:
|
|
790
|
+
if not action_step:
|
|
791
|
+
action_step = (step, step_input, tools_from_input)
|
|
792
|
+
|
|
793
|
+
# --- Emit router target (golden_type = "topic") ---
|
|
794
|
+
if router_step:
|
|
795
|
+
r_step, r_input, r_tools = router_step
|
|
796
|
+
r_msgs = r_input.get('gen_ai.input.messages', [])
|
|
797
|
+
r_tool_name, r_tool_args = _extract_tool_call_from_step_output(r_step)
|
|
798
|
+
replay_targets.append({
|
|
799
|
+
'session_id': session_id,
|
|
800
|
+
'turn_index': turn_idx,
|
|
801
|
+
'user_utterance': user_msg,
|
|
802
|
+
'topic': topic,
|
|
803
|
+
'golden_type': 'topic',
|
|
804
|
+
'original_tool_name': r_tool_name,
|
|
805
|
+
'original_tool_args': r_tool_args,
|
|
806
|
+
'original_text': output_msg,
|
|
807
|
+
'prompt': (llm_step_map.get(r_step.get('step_id', ''), {})
|
|
808
|
+
.get('prompt', '')),
|
|
809
|
+
'step_name': r_step.get('name', ''),
|
|
810
|
+
'step_tools': r_tools,
|
|
811
|
+
'step_messages': r_msgs,
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
# --- Emit action target (golden_type = "action") ---
|
|
815
|
+
if action_step:
|
|
816
|
+
a_step, a_input, a_tools = action_step
|
|
817
|
+
a_msgs = a_input.get('gen_ai.input.messages', [])
|
|
818
|
+
a_tool_name, a_tool_args = _extract_tool_call_from_step_output(a_step)
|
|
819
|
+
|
|
820
|
+
# Fallback: fold ACTION_STEP tool call onto this target
|
|
821
|
+
if not a_tool_name:
|
|
822
|
+
for step in turn.get('steps', []):
|
|
823
|
+
if step.get('step_type') == 'ACTION_STEP':
|
|
824
|
+
try:
|
|
825
|
+
si = json.loads(step.get('input', '{}') or '{}')
|
|
826
|
+
name = si.get('actionName', '').split('.')[-1]
|
|
827
|
+
if name:
|
|
828
|
+
a_tool_name = name
|
|
829
|
+
args = si.get('actionInput', '')
|
|
830
|
+
a_tool_args = json.dumps(args) if isinstance(args, dict) else (args or '')
|
|
831
|
+
break
|
|
832
|
+
except Exception:
|
|
833
|
+
pass
|
|
834
|
+
|
|
835
|
+
# Fallback: check llm_step_map
|
|
836
|
+
if not a_tool_name:
|
|
837
|
+
step_id = a_step.get('step_id')
|
|
838
|
+
if step_id in llm_step_map:
|
|
839
|
+
resp = llm_step_map[step_id].get('llm_response', '') or ''
|
|
840
|
+
name, args, _ = extract_original_tool_call(resp)
|
|
841
|
+
if name:
|
|
842
|
+
a_tool_name = name
|
|
843
|
+
a_tool_args = args
|
|
844
|
+
|
|
845
|
+
replay_targets.append({
|
|
846
|
+
'session_id': session_id,
|
|
847
|
+
'turn_index': turn_idx,
|
|
848
|
+
'user_utterance': user_msg,
|
|
849
|
+
'topic': topic,
|
|
850
|
+
'golden_type': 'action',
|
|
851
|
+
'original_tool_name': a_tool_name,
|
|
852
|
+
'original_tool_args': a_tool_args,
|
|
853
|
+
'original_text': output_msg,
|
|
854
|
+
'prompt': (llm_step_map.get(a_step.get('step_id', ''), {})
|
|
855
|
+
.get('prompt', '')),
|
|
856
|
+
'step_name': a_step.get('name', ''),
|
|
857
|
+
'step_tools': a_tools,
|
|
858
|
+
'step_messages': a_msgs,
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
topic_count = sum(1 for t in replay_targets if t['golden_type'] == 'topic')
|
|
862
|
+
action_count = sum(1 for t in replay_targets if t['golden_type'] == 'action')
|
|
863
|
+
print(f" Built {len(replay_targets)} replay targets ({topic_count} router, {action_count} action)")
|
|
864
|
+
for t in replay_targets:
|
|
865
|
+
print(f" Turn {t['turn_index']} [{t['golden_type']:6s}]: tool={t['original_tool_name']} user=\"{t['user_utterance'][:55]}\"")
|
|
866
|
+
|
|
867
|
+
# 3. Replay via SFAP
|
|
868
|
+
print(f"\n[Phase 2] Replaying via SFAP (model: {REPLAY_MODEL})...")
|
|
869
|
+
results = []
|
|
870
|
+
|
|
871
|
+
for i, target in enumerate(replay_targets):
|
|
872
|
+
gtype = target.get('golden_type', '?')
|
|
873
|
+
print(f"\n [{i+1}/{len(replay_targets)}] Turn {target['turn_index']} [{gtype}]: \"{target['user_utterance'][:50]}\"")
|
|
874
|
+
|
|
875
|
+
# Parse messages from prompt text, or fall back to structured STDM messages
|
|
876
|
+
messages = parse_prompt_to_messages(target['prompt'])
|
|
877
|
+
if not messages and target.get('step_messages'):
|
|
878
|
+
messages = convert_stdm_messages_for_sfap(target['step_messages'])
|
|
879
|
+
if not messages:
|
|
880
|
+
print(f" SKIP: Could not parse prompt into messages")
|
|
881
|
+
continue
|
|
882
|
+
|
|
883
|
+
# Use tool definitions from STDM step input if available, else fall back
|
|
884
|
+
tools = target.get('step_tools')
|
|
885
|
+
if tools:
|
|
886
|
+
tools = sanitize_tools_for_sfap(tools)
|
|
887
|
+
else:
|
|
888
|
+
has_tools = prompt_has_tools(target['prompt'])
|
|
889
|
+
tools = build_tools_for_step(target.get('original_tool_name'), target['prompt']) if has_tools else None
|
|
890
|
+
|
|
891
|
+
print(f" Messages: {len(messages)}, Tools: {'yes' if tools else 'no'}")
|
|
892
|
+
|
|
893
|
+
# Call SFAP
|
|
894
|
+
try:
|
|
895
|
+
sfap_resp = call_sfap(messages, REPLAY_MODEL, tools=tools, max_tokens=2048)
|
|
896
|
+
except Exception as e:
|
|
897
|
+
print(f" ERROR: {e}")
|
|
898
|
+
results.append({**target, 'replayed_tool_name': None, 'replayed_tool_args': None,
|
|
899
|
+
'replayed_text': str(e), 'replay_error': str(e),
|
|
900
|
+
'replay_messages': messages, 'replay_tools': tools})
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
if "error" in sfap_resp:
|
|
904
|
+
print(f" ERROR: {sfap_resp['error']}")
|
|
905
|
+
results.append({**target, 'replayed_tool_name': None, 'replayed_tool_args': None,
|
|
906
|
+
'replayed_text': sfap_resp.get('body', ''), 'replay_error': sfap_resp['error'],
|
|
907
|
+
'replay_messages': messages, 'replay_tools': tools})
|
|
908
|
+
continue
|
|
909
|
+
|
|
910
|
+
# Extract tool call from SFAP response
|
|
911
|
+
rep_tool_name, rep_tool_args, rep_text = extract_tool_call(sfap_resp)
|
|
912
|
+
|
|
913
|
+
print(f" Original: tool={target['original_tool_name']}, args={str(target['original_tool_args'])[:80]}")
|
|
914
|
+
print(f" Replayed: tool={rep_tool_name}, args={str(rep_tool_args)[:80]}")
|
|
915
|
+
if rep_text:
|
|
916
|
+
print(f" Text: {str(rep_text)[:100]}")
|
|
917
|
+
|
|
918
|
+
results.append({
|
|
919
|
+
**target,
|
|
920
|
+
'replayed_tool_name': rep_tool_name,
|
|
921
|
+
'replayed_tool_args': rep_tool_args,
|
|
922
|
+
'replayed_text': rep_text,
|
|
923
|
+
'replay_error': None,
|
|
924
|
+
'replay_messages': messages,
|
|
925
|
+
'replay_tools': tools,
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
# Small delay to avoid rate limiting
|
|
929
|
+
time.sleep(0.5)
|
|
930
|
+
|
|
931
|
+
# 4. Score
|
|
932
|
+
print(f"\n[Phase 3] Scoring...")
|
|
933
|
+
csv_rows = []
|
|
934
|
+
|
|
935
|
+
for r in results:
|
|
936
|
+
row = {
|
|
937
|
+
'session_id': r['session_id'],
|
|
938
|
+
'utterance': r['user_utterance'],
|
|
939
|
+
'golden_type': r.get('golden_type', ''),
|
|
940
|
+
'original_model': 'openai/gpt-4.1',
|
|
941
|
+
'replay_model': REPLAY_MODEL,
|
|
942
|
+
'actual_topic': r.get('topic', ''),
|
|
943
|
+
'replayed_topic': r.get('topic', ''),
|
|
944
|
+
'topic_match': 1 if r.get('topic') else '',
|
|
945
|
+
'actual_action': r.get('original_tool_name', ''),
|
|
946
|
+
'replayed_action': r.get('replayed_tool_name', ''),
|
|
947
|
+
'function_match_score': '',
|
|
948
|
+
'function_args_score': '',
|
|
949
|
+
'actual_outcome': r.get('original_text', ''),
|
|
950
|
+
'replayed_outcome': r.get('replayed_text', ''),
|
|
951
|
+
'text_resp_score': '',
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
# Function name match
|
|
955
|
+
if r.get('original_tool_name') and r.get('replayed_tool_name'):
|
|
956
|
+
row['function_match_score'] = 1 if r['original_tool_name'] == r['replayed_tool_name'] else 0
|
|
957
|
+
print(f" Turn {r['turn_index']} [{r.get('golden_type', '?')}]: func match = {row['function_match_score']} "
|
|
958
|
+
f"({r['original_tool_name']} vs {r['replayed_tool_name']})")
|
|
959
|
+
|
|
960
|
+
# Judge arguments on exact OR lenient name match
|
|
961
|
+
lenient = names_match_lenient(r['original_tool_name'], r['replayed_tool_name'])
|
|
962
|
+
if (row['function_match_score'] == 1 or lenient) and r.get('original_tool_args') and r.get('replayed_tool_args'):
|
|
963
|
+
label = "exact" if row['function_match_score'] == 1 else "lenient"
|
|
964
|
+
print(f" Judging arguments ({label} name match)...")
|
|
965
|
+
score, reasoning = judge_args(
|
|
966
|
+
r['replayed_tool_args'], r['original_tool_args'],
|
|
967
|
+
r['original_tool_name'], r['user_utterance'], JUDGE_MODEL
|
|
968
|
+
)
|
|
969
|
+
row['function_args_score'] = score if score is not None else ''
|
|
970
|
+
r['args_reasoning'] = reasoning or ''
|
|
971
|
+
print(f" Args score: {score} - {str(reasoning)[:100]}")
|
|
972
|
+
time.sleep(0.5)
|
|
973
|
+
elif r.get('original_tool_name') and not r.get('replayed_tool_name'):
|
|
974
|
+
row['function_match_score'] = 0
|
|
975
|
+
print(f" Turn {r['turn_index']}: func match = 0 (expected {r['original_tool_name']}, got text)")
|
|
976
|
+
elif not r.get('original_tool_name') and r.get('replayed_tool_name'):
|
|
977
|
+
row['function_match_score'] = 0
|
|
978
|
+
print(f" Turn {r['turn_index']}: func match = 0 (expected text, got {r['replayed_tool_name']})")
|
|
979
|
+
|
|
980
|
+
# Text response scoring — for turns where replayed model returned text
|
|
981
|
+
if r.get('original_text') and r.get('replayed_text'):
|
|
982
|
+
print(f" Turn {r['turn_index']}: Judging text response...")
|
|
983
|
+
score, reasoning = judge_text(
|
|
984
|
+
r['replayed_text'], r['original_text'], r['user_utterance'], JUDGE_MODEL
|
|
985
|
+
)
|
|
986
|
+
row['text_resp_score'] = score if score is not None else ''
|
|
987
|
+
r['text_reasoning'] = reasoning or ''
|
|
988
|
+
print(f" Text score: {score} - {str(reasoning)[:100]}")
|
|
989
|
+
time.sleep(0.5)
|
|
990
|
+
|
|
991
|
+
csv_rows.append(row)
|
|
992
|
+
|
|
993
|
+
# 5. Write CSV
|
|
994
|
+
print(f"\n[Phase 4] Writing CSV to {OUTPUT_CSV}...")
|
|
995
|
+
fieldnames = [
|
|
996
|
+
'session_id', 'utterance', 'golden_type', 'original_model', 'replay_model',
|
|
997
|
+
'actual_topic', 'replayed_topic', 'topic_match',
|
|
998
|
+
'actual_action', 'replayed_action', 'function_match_score',
|
|
999
|
+
'function_args_score',
|
|
1000
|
+
'actual_outcome', 'replayed_outcome', 'text_resp_score'
|
|
1001
|
+
]
|
|
1002
|
+
with open(OUTPUT_CSV, 'w', newline='') as f:
|
|
1003
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
1004
|
+
writer.writeheader()
|
|
1005
|
+
writer.writerows(csv_rows)
|
|
1006
|
+
|
|
1007
|
+
# 5b. Write enriched JSON for HTML report
|
|
1008
|
+
print(f" Writing JSON to {OUTPUT_JSON}...")
|
|
1009
|
+
report_data = build_report_json(results, csv_rows)
|
|
1010
|
+
with open(OUTPUT_JSON, 'w') as f:
|
|
1011
|
+
json.dump(report_data, f, indent=2, default=str)
|
|
1012
|
+
|
|
1013
|
+
# 6. Summary
|
|
1014
|
+
print(f"\n{'=' * 60}")
|
|
1015
|
+
print(f"REPLAY SUMMARY")
|
|
1016
|
+
print(f"{'=' * 60}")
|
|
1017
|
+
print(f"Sessions: {len(set(r['session_id'] for r in results))}")
|
|
1018
|
+
print(f"Total turns replayed: {len(results)}")
|
|
1019
|
+
print(f"Replay model: {REPLAY_MODEL}")
|
|
1020
|
+
|
|
1021
|
+
func_matches = [r['function_match_score'] for r in csv_rows if r['function_match_score'] != '']
|
|
1022
|
+
func_args = [r['function_args_score'] for r in csv_rows if r['function_args_score'] != '']
|
|
1023
|
+
text_scores = [r['text_resp_score'] for r in csv_rows if r['text_resp_score'] != '']
|
|
1024
|
+
topic_matches = [r['topic_match'] for r in csv_rows if r['topic_match'] != '']
|
|
1025
|
+
|
|
1026
|
+
if topic_matches:
|
|
1027
|
+
print(f"\nTopic Agreement: {sum(topic_matches)/len(topic_matches)*100:.2f}% ({len(topic_matches)} turns)")
|
|
1028
|
+
if func_matches:
|
|
1029
|
+
print(f"Function Name Accuracy: {sum(func_matches)/len(func_matches):.4f} ({len(func_matches)} turns)")
|
|
1030
|
+
if func_args:
|
|
1031
|
+
print(f"Function Argument Accuracy: {sum(func_args)/len(func_args):.4f} ({len(func_args)} turns)")
|
|
1032
|
+
if text_scores:
|
|
1033
|
+
print(f"Text Response Accuracy: {sum(text_scores)/len(text_scores):.4f} ({len(text_scores)} turns)")
|
|
1034
|
+
|
|
1035
|
+
# Breakdown by golden_type
|
|
1036
|
+
for gtype in ('topic', 'action'):
|
|
1037
|
+
gt_rows = [r for r in csv_rows if r.get('golden_type') == gtype]
|
|
1038
|
+
if not gt_rows:
|
|
1039
|
+
continue
|
|
1040
|
+
gt_func = [r['function_match_score'] for r in gt_rows if r['function_match_score'] != '']
|
|
1041
|
+
gt_args = [r['function_args_score'] for r in gt_rows if r['function_args_score'] != '']
|
|
1042
|
+
label = "Router" if gtype == "topic" else "Action"
|
|
1043
|
+
print(f"\n [{label} steps: {len(gt_rows)} targets]")
|
|
1044
|
+
if gt_func:
|
|
1045
|
+
print(f" Function Name Accuracy: {sum(gt_func)/len(gt_func):.4f} ({len(gt_func)} turns)")
|
|
1046
|
+
if gt_args:
|
|
1047
|
+
print(f" Function Argument Accuracy: {sum(gt_args)/len(gt_args):.4f} ({len(gt_args)} turns)")
|
|
1048
|
+
|
|
1049
|
+
print(f"\nCSV saved to: {OUTPUT_CSV}")
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
if __name__ == '__main__':
|
|
1053
|
+
main()
|