@salesforce/afv-skills 1.66.0 → 1.68.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/README.md +55 -5
- package/package.json +2 -4
- package/skills/agentforce-offline-experimentation-run/SKILL.md +26 -49
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/commerce-react-storefront-create/SKILL.md +350 -0
- package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +2 -2
- package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +28 -16
- package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +18 -1
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +22 -24
- package/skills/education-cloud-domain-configure/references/dependent-features.md +9 -0
- package/skills/education-cloud-domain-configure/references/domain-features.md +15 -0
- package/skills/education-cloud-domain-configure/references/gotchas-extended.md +8 -0
- package/skills/education-cloud-domain-configure/references/setup-widgets.md +123 -0
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +19 -13
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/experience-search-coordinate/references/media-route.md +30 -4
- package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -1
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-deploy/README.md +1 -1
- package/skills/platform-metadata-deploy/SKILL.md +114 -15
- package/skills/platform-metadata-deploy/references/agent-deployment-guide.md +3 -4
- package/skills/platform-metadata-deploy/references/orchestration.md +2 -3
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/platform-value-set-generate/SKILL.md +19 -19
- package/skills/platform-widget-generate/SKILL.md +25 -51
- package/skills/platform-widget-generate/examples/formulas.json +19 -2
- package/skills/platform-widget-generate/examples/localized-labels.json +57 -0
- package/skills/platform-widget-generate/references/widget-actions.md +29 -0
- package/skills/platform-widget-generate/references/widget-bundle-validation.md +1 -0
- package/skills/platform-widget-generate/references/widget-formulas.md +24 -7
- package/skills/platform-widget-generate/references/widget-gotchas.md +10 -0
- package/skills/platform-widget-generate/references/widget-labels.md +78 -0
- package/skills/platform-widget-generate/references/widget-styling.md +12 -0
- package/skills/sales-call-scoring-configure/SKILL.md +17 -10
- package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +55 -33
- package/skills/service-digital-engagement-channel-configure/SKILL.md +11 -7
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +123 -16
- package/skills/service-digital-engagement-deployment-configure/SKILL.md +37 -100
- package/skills/service-digital-engagement-deployment-configure/assets/esd_web_update_template.xml +83 -8
- package/skills/service-digital-engagement-deployment-configure/examples/esd_web_prechat_fields.xml +74 -0
- package/skills/service-digital-engagement-deployment-configure/references/connect_api_creation.md +13 -3
- package/skills/service-digital-engagement-deployment-configure/references/deployment_settings.md +13 -78
- package/skills/service-digital-engagement-deployment-configure/references/onboarding_contract.md +5 -6
- package/skills/service-digital-engagement-deployment-configure/references/prechat_forms.md +245 -0
- package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +164 -120
- package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +61 -37
- package/skills/service-itsm-agentic-setup-incident-management/references/audit-tracking.md +42 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/broadcast-communications.md +23 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/email-to-incident.md +23 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-dashboards.md +25 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +30 -146
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +32 -136
- package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +32 -155
- package/skills/service-itsm-agentic-setup-incident-management/references/manage-incidents-with-ai.md +45 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/priority-matrix.md +32 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +30 -140
- package/skills/service-itsm-agentic-setup-incident-management/references/sla-milestones.md +110 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Read an agentscript `.agent` file: the texts a step's request is rendered from.
|
|
2
|
+
|
|
3
|
+
The patch hook (patch.py) and version attribution (versions.py) need, per node, the
|
|
4
|
+
instruction text blocks, the bindings that become the step's tools, and the action
|
|
5
|
+
definitions whose descriptions those tools carry. This is a line parser for exactly that,
|
|
6
|
+
not a compiler: procedure logic (set, run, conditions) is skipped, except that a text block
|
|
7
|
+
inside an `if` / `else` is marked conditional.
|
|
8
|
+
|
|
9
|
+
How the runtime renders these texts (verified on bt-orgfarm Shopper traces, 2026-10-09, and
|
|
10
|
+
bt-airline Voice_English_Airline traces, 2026-10-14):
|
|
11
|
+
- a `| text` block is dedented by the column its text starts at (`|text`: right after the
|
|
12
|
+
bar); a bare `|` block (text on the lines below) and a `key: |` block by the indent of
|
|
13
|
+
their first text line
|
|
14
|
+
- {!@actions.X} renders as X; other merge fields render as their values
|
|
15
|
+
- a line whose text starts with a merge field loses all its leading whitespace
|
|
16
|
+
- system.instructions ends the first system message, or, on a voice agent, sits in it as
|
|
17
|
+
whole lines between the platform's own texts; a node's instruction text is the last
|
|
18
|
+
system message, before "\\n\\n# CONTEXT"
|
|
19
|
+
- an action tool is named after its reasoning binding and carries the action definition's
|
|
20
|
+
description; the binding's LLM-filled inputs (`with p = ...`) are its parameters, with
|
|
21
|
+
the input descriptions; inputs bound to an expression are not offered
|
|
22
|
+
- a transition tool carries its binding's description plus TRANSITION_SUFFIX. Bindings are
|
|
23
|
+
per node, so one transition (or action) name can have a different description per node
|
|
24
|
+
Not verified (no matching traces): how several text blocks of one node are joined, and what
|
|
25
|
+
an `if` branch renders — patch ops stay within one block.
|
|
26
|
+
|
|
27
|
+
python3 scripts/btlib/agentsrc.py AGENT_OR_BUNDLE_DIR [--node NAME] [--rendered]
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from collections import Counter
|
|
35
|
+
|
|
36
|
+
TRANSITION_SUFFIX = ". Never use __user_input__ to collect inputs for this action."
|
|
37
|
+
CONTEXT_MARKER = "\n\n# CONTEXT" # ends a node's instruction text in the last system message
|
|
38
|
+
MERGE_FIELD = re.compile(r"\{!([^{}]*)\}")
|
|
39
|
+
NODE_KINDS = ("start_agent", "subagent", "topic")
|
|
40
|
+
_ACTION_REF = re.compile(r"^@actions\.(\w+)$")
|
|
41
|
+
_TRANSITION = re.compile(r"^@utils\.transition\s+to\s+@(?:subagent|topic)\.(\w+)$")
|
|
42
|
+
_STATEMENT = re.compile(r"^(if|elif|else|with|set|run|transition|available)\b")
|
|
43
|
+
_CONDITIONAL = re.compile(r"^(if|elif|else)\b")
|
|
44
|
+
_WITH = re.compile(r"^with\s+[\"']?(\w+)[\"']?\s*=\s*(.*)$")
|
|
45
|
+
_KEY = re.compile(r"^([^:\"]+?)\s*:(?:\s+(.*))?$")
|
|
46
|
+
_QUOTED_KEY = re.compile(r'^"((?:[^"\\]|\\.)*)"\s*:\s*(.*)$')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Block:
|
|
50
|
+
"""One text block: its source lines and its dedented text (merge fields as written)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, lines, base, style, first_line=None):
|
|
53
|
+
self.lines = lines # [(1-based line number, raw line)]
|
|
54
|
+
self.base = base # dedent column
|
|
55
|
+
self.style = style # "inline" (| text), "bare" (|), "block" (key: |)
|
|
56
|
+
self.conditional = False # inside an if / elif / else
|
|
57
|
+
self.start = lines[0][0] if lines else first_line
|
|
58
|
+
self.end = lines[-1][0] if lines else first_line
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def text(self):
|
|
62
|
+
out = []
|
|
63
|
+
for i, (_, raw) in enumerate(self.lines):
|
|
64
|
+
if i == 0 and self.style == "inline":
|
|
65
|
+
out.append(raw[self.base:])
|
|
66
|
+
elif not raw.strip():
|
|
67
|
+
out.append("")
|
|
68
|
+
elif _indent(raw) >= self.base:
|
|
69
|
+
out.append(raw[self.base:])
|
|
70
|
+
else:
|
|
71
|
+
out.append(raw.lstrip(" "))
|
|
72
|
+
return "\n".join(out)
|
|
73
|
+
|
|
74
|
+
def rendered(self):
|
|
75
|
+
"""The text as the runtime renders it; non-action merge fields stay in place."""
|
|
76
|
+
return render_text(self.text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Input:
|
|
80
|
+
def __init__(self, name, line, type_):
|
|
81
|
+
self.name, self.line, self.type = name, line, type_
|
|
82
|
+
self.description = None
|
|
83
|
+
self.label = None
|
|
84
|
+
self.is_required = False
|
|
85
|
+
self.is_user_input = None
|
|
86
|
+
self.item = None # the parsed entry (agentpatch.py edits its description)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ActionDef:
|
|
90
|
+
def __init__(self, name, line):
|
|
91
|
+
self.name, self.line = name, line
|
|
92
|
+
self.description = None
|
|
93
|
+
self.label = None
|
|
94
|
+
self.target = None
|
|
95
|
+
self.inputs = {}
|
|
96
|
+
self.item = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Binding:
|
|
100
|
+
"""A reasoning action: becomes one tool of the node's steps."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, name, line, target):
|
|
103
|
+
self.name, self.line, self.target = name, line, target
|
|
104
|
+
self.kind, self.action, self.to = "other", None, None
|
|
105
|
+
m = _ACTION_REF.match(target or "")
|
|
106
|
+
if m:
|
|
107
|
+
self.kind, self.action = "action", m.group(1)
|
|
108
|
+
m = _TRANSITION.match(target or "")
|
|
109
|
+
if m:
|
|
110
|
+
self.kind, self.to = "transition", m.group(1)
|
|
111
|
+
self.description = None
|
|
112
|
+
self.description_line = None
|
|
113
|
+
self.llm_inputs = [] # with p = ...
|
|
114
|
+
self.bound_inputs = {} # with p = <expression>
|
|
115
|
+
self.available_when = None
|
|
116
|
+
self.item = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Node:
|
|
120
|
+
def __init__(self, kind, name, line):
|
|
121
|
+
self.kind, self.name, self.line = kind, name, line
|
|
122
|
+
self.label = None
|
|
123
|
+
self.description = None
|
|
124
|
+
self.model = None
|
|
125
|
+
self.system = None # node-level system.instructions override
|
|
126
|
+
self.blocks = [] # reasoning instruction text blocks, in order
|
|
127
|
+
self.procedure = False # instructions given as a procedure (-> or statements)
|
|
128
|
+
self.bindings = {}
|
|
129
|
+
self.action_defs = {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AgentSource:
|
|
133
|
+
def __init__(self, path=None):
|
|
134
|
+
self.path = path
|
|
135
|
+
self.target = None # bundle-meta <target>, e.g. Shopper_Agent.v3
|
|
136
|
+
self.label = None # version label, e.g. v3
|
|
137
|
+
self.developer_name = None
|
|
138
|
+
self.model = None
|
|
139
|
+
self.system = None # global system.instructions block
|
|
140
|
+
self.nodes = {}
|
|
141
|
+
|
|
142
|
+
def action_def(self, node, name):
|
|
143
|
+
"""The definition a binding refers to: the node's own first, then any node's."""
|
|
144
|
+
if name in node.action_defs:
|
|
145
|
+
return node.action_defs[name]
|
|
146
|
+
for other in self.nodes.values():
|
|
147
|
+
if name in other.action_defs:
|
|
148
|
+
return other.action_defs[name]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def node_tools(self, node_name):
|
|
152
|
+
"""{tool name: {kind, description, params {name: description}, required, conditional}}
|
|
153
|
+
for the tools a node's steps are offered (platform tools such as __end_session_action__
|
|
154
|
+
are not in the .agent)."""
|
|
155
|
+
node = self.nodes.get(node_name)
|
|
156
|
+
out = {}
|
|
157
|
+
if node is None:
|
|
158
|
+
return out
|
|
159
|
+
for b in node.bindings.values():
|
|
160
|
+
tool = {"kind": b.kind, "description": None, "params": {}, "required": [],
|
|
161
|
+
"conditional": b.available_when is not None, "line": b.line}
|
|
162
|
+
if b.kind == "transition":
|
|
163
|
+
if b.description is not None:
|
|
164
|
+
tool["description"] = b.description + TRANSITION_SUFFIX
|
|
165
|
+
elif b.kind == "action":
|
|
166
|
+
d = self.action_def(node, b.action)
|
|
167
|
+
if d is not None:
|
|
168
|
+
tool["description"] = d.description
|
|
169
|
+
for p in b.llm_inputs:
|
|
170
|
+
inp = d.inputs.get(p)
|
|
171
|
+
tool["params"][p] = inp.description if inp else None
|
|
172
|
+
if inp and inp.is_required:
|
|
173
|
+
tool["required"].append(p)
|
|
174
|
+
else:
|
|
175
|
+
tool["params"] = {p: None for p in b.llm_inputs}
|
|
176
|
+
else:
|
|
177
|
+
continue
|
|
178
|
+
out[b.name] = tool
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AgentSourceError(ValueError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------------------
|
|
187
|
+
# rendering
|
|
188
|
+
|
|
189
|
+
def _action_name(field):
|
|
190
|
+
m = _ACTION_REF.match(field.strip())
|
|
191
|
+
return m.group(1) if m else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def render_text(text):
|
|
195
|
+
"""Text as rendered: {!@actions.X} -> X, and a line starting with a merge field loses its
|
|
196
|
+
leading whitespace. Other merge fields stay as written (their values are not known)."""
|
|
197
|
+
lines = [ln.lstrip() if ln.lstrip().startswith("{!") else ln for ln in text.split("\n")]
|
|
198
|
+
return MERGE_FIELD.sub(lambda m: _action_name(m.group(1)) or m.group(0), "\n".join(lines))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def value_fields(text):
|
|
202
|
+
"""The non-action merge fields of a text, in order of first appearance."""
|
|
203
|
+
out = []
|
|
204
|
+
for m in MERGE_FIELD.finditer(text):
|
|
205
|
+
if _action_name(m.group(1)) is None and m.group(0) not in out:
|
|
206
|
+
out.append(m.group(0))
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fragment_pattern(text, prefix="mf"):
|
|
211
|
+
"""(regex source, {field: group name}) finding the rendered form of `text`.
|
|
212
|
+
|
|
213
|
+
Each non-action merge field becomes a capture group that matches whatever value the
|
|
214
|
+
field rendered to (a repeat of the field is a backreference). The group is lazy and may
|
|
215
|
+
not contain the literal just before it, so the match takes the shortest value even when
|
|
216
|
+
that literal occurs earlier in the text too. Group names are prefix + a number, so the
|
|
217
|
+
patterns of several texts can be combined.
|
|
218
|
+
"""
|
|
219
|
+
rendered = render_text(text)
|
|
220
|
+
parts, groups, pos = [], {}, 0
|
|
221
|
+
for m in MERGE_FIELD.finditer(rendered):
|
|
222
|
+
literal = rendered[pos:m.start()]
|
|
223
|
+
parts.append(re.escape(literal))
|
|
224
|
+
field = m.group(0)
|
|
225
|
+
if field in groups:
|
|
226
|
+
parts.append(f"(?P={groups[field]})")
|
|
227
|
+
else:
|
|
228
|
+
groups[field] = f"{prefix}{len(groups)}"
|
|
229
|
+
char = f"(?:(?!{re.escape(literal)})[\\s\\S])" if literal else "[\\s\\S]"
|
|
230
|
+
parts.append(f"(?P<{groups[field]}>{char}*?)")
|
|
231
|
+
pos = m.end()
|
|
232
|
+
parts.append(re.escape(rendered[pos:]))
|
|
233
|
+
return "".join(parts), groups
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------------------
|
|
237
|
+
# parsing
|
|
238
|
+
|
|
239
|
+
class _Item:
|
|
240
|
+
__slots__ = ("indent", "line", "key", "value", "block", "children")
|
|
241
|
+
|
|
242
|
+
def __init__(self, indent, line, key, value, block=None):
|
|
243
|
+
self.indent, self.line, self.key, self.value, self.block = indent, line, key, value, block
|
|
244
|
+
self.children = []
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _indent(raw):
|
|
248
|
+
return len(raw) - len(raw.lstrip(" "))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _split_key(s):
|
|
252
|
+
"""('key', 'value') for `key: value` / `"key": value`; (None, s) for a statement."""
|
|
253
|
+
if _STATEMENT.match(s):
|
|
254
|
+
return None, s
|
|
255
|
+
m = _QUOTED_KEY.match(s) if s.startswith('"') else _KEY.match(s)
|
|
256
|
+
if m:
|
|
257
|
+
return m.group(1), (m.group(2) or "").strip()
|
|
258
|
+
return None, s
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _take_block(lines, j, min_indent, head=None):
|
|
262
|
+
"""Lines from index j that are blank or indented deeper than min_indent; trailing blanks
|
|
263
|
+
dropped. head: the inline first line (line number, raw), if any."""
|
|
264
|
+
body = [head] if head else []
|
|
265
|
+
while j < len(lines):
|
|
266
|
+
raw = lines[j]
|
|
267
|
+
if raw.strip() and _indent(raw) <= min_indent:
|
|
268
|
+
break
|
|
269
|
+
body.append((j + 1, raw))
|
|
270
|
+
j += 1
|
|
271
|
+
while len(body) > (1 if head else 0) and not body[-1][1].strip():
|
|
272
|
+
body.pop()
|
|
273
|
+
return body, j
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _first_text_indent(body):
|
|
277
|
+
for _, raw in body:
|
|
278
|
+
if raw.strip():
|
|
279
|
+
return _indent(raw)
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _scan(lines):
|
|
284
|
+
"""Flat items in file order; text blocks are consumed whole (their lines can hold anything)."""
|
|
285
|
+
items, i = [], 0
|
|
286
|
+
while i < len(lines):
|
|
287
|
+
raw = lines[i]
|
|
288
|
+
s = raw.strip()
|
|
289
|
+
if not s or s.startswith("#"):
|
|
290
|
+
i += 1
|
|
291
|
+
continue
|
|
292
|
+
ind = _indent(raw)
|
|
293
|
+
if s.startswith("|"): # |, | text, |text
|
|
294
|
+
if s == "|":
|
|
295
|
+
body, nxt = _take_block(lines, i + 1, ind)
|
|
296
|
+
block = Block(body, _first_text_indent(body), "bare", first_line=i + 1)
|
|
297
|
+
else:
|
|
298
|
+
text_col = ind + 1 + _indent(raw[ind + 1:])
|
|
299
|
+
body, nxt = _take_block(lines, i + 1, ind, head=(i + 1, raw))
|
|
300
|
+
block = Block(body, text_col, "inline")
|
|
301
|
+
items.append(_Item(ind, i + 1, None, "|", block))
|
|
302
|
+
i = nxt
|
|
303
|
+
continue
|
|
304
|
+
key, value = _split_key(s)
|
|
305
|
+
if key is not None and value.startswith("|"):
|
|
306
|
+
if value == "|":
|
|
307
|
+
body, nxt = _take_block(lines, i + 1, ind)
|
|
308
|
+
block = Block(body, _first_text_indent(body), "block", first_line=i + 1)
|
|
309
|
+
else:
|
|
310
|
+
bar = raw.index("|", raw.index(":"))
|
|
311
|
+
text_col = bar + 1 + _indent(raw[bar + 1:])
|
|
312
|
+
body, nxt = _take_block(lines, i + 1, ind, head=(i + 1, raw))
|
|
313
|
+
block = Block(body, text_col, "inline")
|
|
314
|
+
items.append(_Item(ind, i + 1, key, "|", block))
|
|
315
|
+
i = nxt
|
|
316
|
+
continue
|
|
317
|
+
items.append(_Item(ind, i + 1, key, value))
|
|
318
|
+
i += 1
|
|
319
|
+
return items
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _tree(items):
|
|
323
|
+
root = _Item(-1, 0, None, None)
|
|
324
|
+
stack = [root]
|
|
325
|
+
for it in items:
|
|
326
|
+
while stack[-1].indent >= it.indent:
|
|
327
|
+
stack.pop()
|
|
328
|
+
stack[-1].children.append(it)
|
|
329
|
+
stack.append(it)
|
|
330
|
+
return root
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _str(value):
|
|
334
|
+
if value is None:
|
|
335
|
+
return None
|
|
336
|
+
v = value.strip()
|
|
337
|
+
if v.startswith('"'):
|
|
338
|
+
try:
|
|
339
|
+
return json.loads(v)
|
|
340
|
+
except json.JSONDecodeError:
|
|
341
|
+
return v.strip('"')
|
|
342
|
+
return v
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _bool(value):
|
|
346
|
+
return (value or "").strip().lower() == "true"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _text_value(item):
|
|
350
|
+
return item.block.text if item.block is not None else _str(item.value)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _child(item, key):
|
|
354
|
+
for ch in item.children:
|
|
355
|
+
if ch.key == key:
|
|
356
|
+
return ch
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _instruction_blocks(item):
|
|
361
|
+
"""(blocks, procedure) of a reasoning `instructions` entry."""
|
|
362
|
+
if item.block is not None:
|
|
363
|
+
return [item.block], False
|
|
364
|
+
blocks = []
|
|
365
|
+
|
|
366
|
+
def walk(it, conditional):
|
|
367
|
+
for ch in it.children:
|
|
368
|
+
if ch.block is not None and ch.key is None:
|
|
369
|
+
ch.block.conditional = conditional
|
|
370
|
+
blocks.append(ch.block)
|
|
371
|
+
continue
|
|
372
|
+
walk(ch, conditional or (ch.key is None and bool(_CONDITIONAL.match(ch.value or ""))))
|
|
373
|
+
walk(item, False)
|
|
374
|
+
return blocks, True
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _binding(item):
|
|
378
|
+
b = Binding(item.key, item.line, item.value)
|
|
379
|
+
b.item = item
|
|
380
|
+
for ch in item.children:
|
|
381
|
+
if ch.key == "description":
|
|
382
|
+
b.description = _text_value(ch)
|
|
383
|
+
b.description_line = ch.line
|
|
384
|
+
elif ch.key is None and ch.value:
|
|
385
|
+
m = _WITH.match(ch.value)
|
|
386
|
+
if m:
|
|
387
|
+
if m.group(2).strip() == "...":
|
|
388
|
+
b.llm_inputs.append(m.group(1))
|
|
389
|
+
else:
|
|
390
|
+
b.bound_inputs[m.group(1)] = m.group(2).strip()
|
|
391
|
+
elif ch.value.startswith("available when"):
|
|
392
|
+
b.available_when = ch.value[len("available when"):].strip()
|
|
393
|
+
return b
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _action_def(item):
|
|
397
|
+
d = ActionDef(item.key, item.line)
|
|
398
|
+
d.item = item
|
|
399
|
+
for ch in item.children:
|
|
400
|
+
if ch.key == "description":
|
|
401
|
+
d.description = _text_value(ch)
|
|
402
|
+
elif ch.key == "label":
|
|
403
|
+
d.label = _str(ch.value)
|
|
404
|
+
elif ch.key == "target":
|
|
405
|
+
d.target = _str(ch.value)
|
|
406
|
+
elif ch.key == "inputs":
|
|
407
|
+
for it in ch.children:
|
|
408
|
+
if it.key is None:
|
|
409
|
+
continue
|
|
410
|
+
inp = Input(it.key, it.line, it.value)
|
|
411
|
+
inp.item = it
|
|
412
|
+
for p in it.children:
|
|
413
|
+
if p.key == "description":
|
|
414
|
+
inp.description = _text_value(p)
|
|
415
|
+
elif p.key == "label":
|
|
416
|
+
inp.label = _str(p.value)
|
|
417
|
+
elif p.key == "is_required":
|
|
418
|
+
inp.is_required = _bool(p.value)
|
|
419
|
+
elif p.key == "is_user_input":
|
|
420
|
+
inp.is_user_input = _bool(p.value)
|
|
421
|
+
d.inputs[inp.name] = inp
|
|
422
|
+
return d
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _node(kind, name, item):
|
|
426
|
+
node = Node(kind, name, item.line)
|
|
427
|
+
for ch in item.children:
|
|
428
|
+
if ch.key == "label":
|
|
429
|
+
node.label = _str(ch.value)
|
|
430
|
+
elif ch.key == "description":
|
|
431
|
+
node.description = _text_value(ch)
|
|
432
|
+
elif ch.key == "model_config":
|
|
433
|
+
m = _child(ch, "model")
|
|
434
|
+
node.model = _str(m.value) if m else None
|
|
435
|
+
elif ch.key == "system":
|
|
436
|
+
ins = _child(ch, "instructions")
|
|
437
|
+
node.system = ins.block if ins is not None else None
|
|
438
|
+
elif ch.key == "actions":
|
|
439
|
+
node.action_defs = {d.name: d for d in (_action_def(c) for c in ch.children if c.key)}
|
|
440
|
+
elif ch.key == "reasoning":
|
|
441
|
+
for r in ch.children:
|
|
442
|
+
if r.key == "instructions":
|
|
443
|
+
node.blocks, node.procedure = _instruction_blocks(r)
|
|
444
|
+
elif r.key == "actions":
|
|
445
|
+
node.bindings = {b.name: b for b in (_binding(c) for c in r.children if c.key)}
|
|
446
|
+
return node
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def bundle_target(agent_path):
|
|
450
|
+
"""<target> of the bundle-meta.xml next to an .agent file, e.g. 'Shopper_Agent.v3'."""
|
|
451
|
+
d = os.path.dirname(os.path.abspath(agent_path))
|
|
452
|
+
for name in sorted(os.listdir(d)):
|
|
453
|
+
if name.endswith(".bundle-meta.xml"):
|
|
454
|
+
with open(os.path.join(d, name), encoding="utf-8") as f:
|
|
455
|
+
m = re.search(r"<target>\s*([^<]+?)\s*</target>", f.read())
|
|
456
|
+
if m:
|
|
457
|
+
return m.group(1)
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def parse_agent(text, path=None):
|
|
462
|
+
src = AgentSource(path)
|
|
463
|
+
root = _tree(_scan(text.split("\n")))
|
|
464
|
+
for top in root.children:
|
|
465
|
+
key = top.key or ""
|
|
466
|
+
head = key.split()
|
|
467
|
+
if key == "system":
|
|
468
|
+
ins = _child(top, "instructions")
|
|
469
|
+
src.system = ins.block if ins is not None else None
|
|
470
|
+
elif key == "model_config":
|
|
471
|
+
m = _child(top, "model")
|
|
472
|
+
src.model = _str(m.value) if m else None
|
|
473
|
+
elif key == "config":
|
|
474
|
+
dn = _child(top, "developer_name")
|
|
475
|
+
src.developer_name = _str(dn.value) if dn else None
|
|
476
|
+
elif len(head) == 2 and head[0] in NODE_KINDS:
|
|
477
|
+
if head[1] in src.nodes:
|
|
478
|
+
raise AgentSourceError(f"{path or 'agent'}: node {head[1]} defined twice")
|
|
479
|
+
src.nodes[head[1]] = _node(head[0], head[1], top)
|
|
480
|
+
if not src.nodes:
|
|
481
|
+
raise AgentSourceError(f"{path or 'agent'}: no start_agent / subagent nodes found")
|
|
482
|
+
return src
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def agent_file(path):
|
|
486
|
+
"""path itself, or the one .agent file inside a bundle dir."""
|
|
487
|
+
if not os.path.isdir(path):
|
|
488
|
+
return path
|
|
489
|
+
found = sorted(n for n in os.listdir(path) if n.endswith(".agent"))
|
|
490
|
+
if len(found) != 1:
|
|
491
|
+
raise AgentSourceError(f"{path}: expected one .agent file, found {len(found)}")
|
|
492
|
+
return os.path.join(path, found[0])
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def load_agent(path, label=None):
|
|
496
|
+
"""Parse an .agent file, or the one .agent inside a bundle dir. The label defaults to the
|
|
497
|
+
version suffix of the bundle-meta target ('v3'), else the file name."""
|
|
498
|
+
path = agent_file(path)
|
|
499
|
+
with open(path, encoding="utf-8") as f:
|
|
500
|
+
src = parse_agent(f.read(), path)
|
|
501
|
+
src.target = bundle_target(path)
|
|
502
|
+
if label:
|
|
503
|
+
src.label = label
|
|
504
|
+
elif src.target and "." in src.target:
|
|
505
|
+
src.label = src.target.rsplit(".", 1)[1]
|
|
506
|
+
else:
|
|
507
|
+
src.label = os.path.splitext(os.path.basename(path))[0]
|
|
508
|
+
return src
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _label_key(label):
|
|
512
|
+
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", label)]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def load_versions(path):
|
|
516
|
+
"""The agent versions under path, sorted by label (v2 before v10): an .agent file or bundle
|
|
517
|
+
dir, a dir of .agent files, or a dir of bundle dirs (a retrieved aiAuthoringBundles/)."""
|
|
518
|
+
if os.path.isfile(path):
|
|
519
|
+
found = [load_agent(path)]
|
|
520
|
+
else:
|
|
521
|
+
names = sorted(os.listdir(path))
|
|
522
|
+
files = [n for n in names if n.endswith(".agent")]
|
|
523
|
+
if files:
|
|
524
|
+
found = [load_agent(os.path.join(path, n)) for n in files]
|
|
525
|
+
else:
|
|
526
|
+
found = [load_agent(os.path.join(path, n)) for n in names
|
|
527
|
+
if os.path.isdir(os.path.join(path, n))
|
|
528
|
+
and any(f.endswith(".agent") for f in os.listdir(os.path.join(path, n)))]
|
|
529
|
+
if not found:
|
|
530
|
+
raise AgentSourceError(f"{path}: no .agent files")
|
|
531
|
+
dupes = sorted(label for label, n in Counter(s.label for s in found).items() if n > 1)
|
|
532
|
+
if dupes:
|
|
533
|
+
raise AgentSourceError(f"{path}: version labels {dupes} occur more than once (one bundle dir per version)")
|
|
534
|
+
return sorted(found, key=lambda s: _label_key(s.label))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def main(argv=None):
|
|
538
|
+
import argparse
|
|
539
|
+
p = argparse.ArgumentParser(description="Print the instruction blocks and tools of an .agent file.")
|
|
540
|
+
p.add_argument("agent", help=".agent file or bundle dir")
|
|
541
|
+
p.add_argument("--node", help="only this node")
|
|
542
|
+
p.add_argument("--rendered", action="store_true", help="print blocks as rendered ({!@actions.X} -> X)")
|
|
543
|
+
args = p.parse_args(argv)
|
|
544
|
+
src = load_agent(args.agent)
|
|
545
|
+
print(f"# {src.path} label={src.label} target={src.target} model={src.model}")
|
|
546
|
+
if src.system is not None and not args.node:
|
|
547
|
+
print(f"\n== system.instructions (lines {src.system.start}-{src.system.end})")
|
|
548
|
+
print(src.system.rendered() if args.rendered else src.system.text)
|
|
549
|
+
for node in src.nodes.values():
|
|
550
|
+
if args.node and node.name != args.node:
|
|
551
|
+
continue
|
|
552
|
+
print(f"\n== {node.kind} {node.name} (line {node.line})" + (f" model={node.model}" if node.model else ""))
|
|
553
|
+
for k, b in enumerate(node.blocks):
|
|
554
|
+
flag = " conditional" if b.conditional else ""
|
|
555
|
+
print(f"-- block {k} lines {b.start}-{b.end} {b.style}{flag}")
|
|
556
|
+
print(b.rendered() if args.rendered else b.text)
|
|
557
|
+
for name, tool in src.node_tools(node.name).items():
|
|
558
|
+
print(f"-- tool {name} ({tool['kind']}, line {tool['line']}): {tool['description']!r}")
|
|
559
|
+
for prm, desc in tool["params"].items():
|
|
560
|
+
print(f" {prm}: {desc!r}")
|
|
561
|
+
return 0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
if __name__ == "__main__":
|
|
565
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Result cache (--cache-dir): a request already answered is not sent again.
|
|
2
|
+
|
|
3
|
+
An entry is keyed by the SFAP endpoint, the core tenant and the payload exactly as sent, so the
|
|
4
|
+
model alias, the generation settings and the seed (that is, the repeat) are all in the key: a
|
|
5
|
+
patched request, another model or another repeat is another entry. Judge calls go through the
|
|
6
|
+
same cache. Only answers are stored: an infra error, or a 200 without a generation, is sent
|
|
7
|
+
again by the next run.
|
|
8
|
+
|
|
9
|
+
<cache dir>/<key[:2]>/<key>.json {"schema": "bt.cache/1", "http_status", "latency_ms", "body"}
|
|
10
|
+
|
|
11
|
+
The response body is stored, not the scores, so a change to the scoring re-scores cached answers.
|
|
12
|
+
Entries are written through the redactor, atomically, with mode 0600. They hold the model's
|
|
13
|
+
answers to user conversations: keep the cache dir out of git, like run dirs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
from . import SCHEMA_CACHE
|
|
23
|
+
from .redact import redact_obj
|
|
24
|
+
from .sfap import parse_generation
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CachingClient:
|
|
28
|
+
"""generate(payload) as SfapClient's, answered from cache_dir when the payload was answered before."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client, cache_dir):
|
|
31
|
+
self.client = client
|
|
32
|
+
self.cache_dir = cache_dir
|
|
33
|
+
self.hits = self.calls = self.write_errors = 0
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
if not os.path.isdir(cache_dir):
|
|
36
|
+
os.makedirs(cache_dir)
|
|
37
|
+
os.chmod(cache_dir, 0o700)
|
|
38
|
+
|
|
39
|
+
def key(self, payload):
|
|
40
|
+
blob = json.dumps({"url": self.client.url, "tenant": self.client.core_tenant_id, "payload": payload},
|
|
41
|
+
sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
42
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
def _path(self, key):
|
|
45
|
+
return os.path.join(self.cache_dir, key[:2], key + ".json")
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _load(path):
|
|
49
|
+
try:
|
|
50
|
+
with open(path, encoding="utf-8") as f:
|
|
51
|
+
entry = json.load(f)
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
return entry if isinstance(entry, dict) and entry.get("schema") == SCHEMA_CACHE else None
|
|
55
|
+
|
|
56
|
+
def _store(self, path, entry):
|
|
57
|
+
"""Write the entry atomically; a failure is counted, not raised (the answer is still good)."""
|
|
58
|
+
tmp = None
|
|
59
|
+
try:
|
|
60
|
+
d = os.path.dirname(path)
|
|
61
|
+
if not os.path.isdir(d):
|
|
62
|
+
os.makedirs(d, exist_ok=True)
|
|
63
|
+
os.chmod(d, 0o700)
|
|
64
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".tmp-", suffix=".json") # mode 0600
|
|
65
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
66
|
+
json.dump(redact_obj(entry), f, ensure_ascii=False)
|
|
67
|
+
os.replace(tmp, path)
|
|
68
|
+
except OSError:
|
|
69
|
+
with self._lock:
|
|
70
|
+
self.write_errors += 1
|
|
71
|
+
if tmp and os.path.exists(tmp):
|
|
72
|
+
os.unlink(tmp)
|
|
73
|
+
|
|
74
|
+
def generate(self, payload):
|
|
75
|
+
path = self._path(self.key(payload))
|
|
76
|
+
entry = self._load(path)
|
|
77
|
+
parsed = parse_generation(entry.get("body")) if entry else None
|
|
78
|
+
if parsed is not None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self.hits += 1
|
|
81
|
+
return {"status": "ok", "http_status": entry.get("http_status"), "body": entry["body"],
|
|
82
|
+
"error": None, "error_kind": None, "attempts": 0, "latency_ms": entry.get("latency_ms"),
|
|
83
|
+
"response": parsed, "cached": True}
|
|
84
|
+
res = self.client.generate(payload)
|
|
85
|
+
with self._lock:
|
|
86
|
+
self.calls += 1
|
|
87
|
+
if res["status"] == "ok":
|
|
88
|
+
self._store(path, {"schema": SCHEMA_CACHE, "http_status": res.get("http_status"),
|
|
89
|
+
"latency_ms": res.get("latency_ms"), "body": res["body"]})
|
|
90
|
+
res["cached"] = False
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
def stats(self):
|
|
94
|
+
with self._lock:
|
|
95
|
+
return {"hits": self.hits, "calls": self.calls, "write_errors": self.write_errors}
|