anip-runtime-utils 0.24.10__py3-none-any.whl
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.
- anip_runtime_utils/__init__.py +140 -0
- anip_runtime_utils/agent_consumption.py +2213 -0
- anip_runtime_utils/normalization.py +110 -0
- anip_runtime_utils/preflight.py +52 -0
- anip_runtime_utils-0.24.10.dist-info/METADATA +15 -0
- anip_runtime_utils-0.24.10.dist-info/RECORD +8 -0
- anip_runtime_utils-0.24.10.dist-info/WHEEL +5 -0
- anip_runtime_utils-0.24.10.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2213 @@
|
|
|
1
|
+
"""Helpers for consuming ANIP agent-consumability metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEICTIC_REFERENCE_KEYS = {
|
|
10
|
+
"it",
|
|
11
|
+
"that",
|
|
12
|
+
"thatone",
|
|
13
|
+
"this",
|
|
14
|
+
"thisone",
|
|
15
|
+
"those",
|
|
16
|
+
"these",
|
|
17
|
+
"them",
|
|
18
|
+
"theone",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
GENERIC_CATALOG_TOKENS = {
|
|
22
|
+
"account",
|
|
23
|
+
"accounts",
|
|
24
|
+
"add",
|
|
25
|
+
"after",
|
|
26
|
+
"and",
|
|
27
|
+
"before",
|
|
28
|
+
"candidate",
|
|
29
|
+
"candidates",
|
|
30
|
+
"cohort",
|
|
31
|
+
"company",
|
|
32
|
+
"companies",
|
|
33
|
+
"content",
|
|
34
|
+
"customer",
|
|
35
|
+
"customers",
|
|
36
|
+
"data",
|
|
37
|
+
"deal",
|
|
38
|
+
"deals",
|
|
39
|
+
"draft",
|
|
40
|
+
"drafting",
|
|
41
|
+
"email",
|
|
42
|
+
"enrich",
|
|
43
|
+
"enrichment",
|
|
44
|
+
"entity",
|
|
45
|
+
"entities",
|
|
46
|
+
"for",
|
|
47
|
+
"follow",
|
|
48
|
+
"followup",
|
|
49
|
+
"highest",
|
|
50
|
+
"in",
|
|
51
|
+
"lead",
|
|
52
|
+
"leads",
|
|
53
|
+
"list",
|
|
54
|
+
"linkedin",
|
|
55
|
+
"opportunity",
|
|
56
|
+
"opportunities",
|
|
57
|
+
"outreach",
|
|
58
|
+
"plan",
|
|
59
|
+
"prepare",
|
|
60
|
+
"preview",
|
|
61
|
+
"prioritize",
|
|
62
|
+
"prioritization",
|
|
63
|
+
"priority",
|
|
64
|
+
"record",
|
|
65
|
+
"records",
|
|
66
|
+
"recommendation",
|
|
67
|
+
"recommendations",
|
|
68
|
+
"reference",
|
|
69
|
+
"route",
|
|
70
|
+
"routed",
|
|
71
|
+
"routing",
|
|
72
|
+
"suggest",
|
|
73
|
+
"suggested",
|
|
74
|
+
"summary",
|
|
75
|
+
"show",
|
|
76
|
+
"target",
|
|
77
|
+
"targets",
|
|
78
|
+
"that",
|
|
79
|
+
"the",
|
|
80
|
+
"this",
|
|
81
|
+
"those",
|
|
82
|
+
"three",
|
|
83
|
+
"these",
|
|
84
|
+
"top",
|
|
85
|
+
"to",
|
|
86
|
+
"up",
|
|
87
|
+
"with",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
TEMPORAL_REFERENCE_TOKENS = {"fiscal", "fy", "q1", "q2", "q3", "q4", "quarter", "quarters"}
|
|
91
|
+
|
|
92
|
+
CAPABILITY_SCORING_STOP_TOKENS = {
|
|
93
|
+
"after",
|
|
94
|
+
"and",
|
|
95
|
+
"before",
|
|
96
|
+
"for",
|
|
97
|
+
"in",
|
|
98
|
+
"of",
|
|
99
|
+
"that",
|
|
100
|
+
"the",
|
|
101
|
+
"these",
|
|
102
|
+
"this",
|
|
103
|
+
"those",
|
|
104
|
+
"to",
|
|
105
|
+
"with",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
UNSUPPORTED_EFFECT_TERMS = {
|
|
109
|
+
"approval.execute": {"approve", "apply", "commit", "execute", "perform"},
|
|
110
|
+
"external_dispatch": {"deliver", "dispatch", "publish", "send", "ship"},
|
|
111
|
+
"raw_data_export": {"csv", "download", "dump", "export", "raw", "spreadsheet"},
|
|
112
|
+
"system.mutation": {"apply", "commit", "delete", "mutate", "update"},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
APPROVAL_INTENT_TERMS = {
|
|
116
|
+
"approval",
|
|
117
|
+
"approve",
|
|
118
|
+
"draft",
|
|
119
|
+
"governed",
|
|
120
|
+
"message",
|
|
121
|
+
"plan",
|
|
122
|
+
"prepare",
|
|
123
|
+
"preview",
|
|
124
|
+
"reassign",
|
|
125
|
+
"reassignment",
|
|
126
|
+
"recommendation",
|
|
127
|
+
"recommendations",
|
|
128
|
+
"route",
|
|
129
|
+
"routing",
|
|
130
|
+
"task",
|
|
131
|
+
"tasks",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
NEGATION_TERMS = {"avoid", "do", "dont", "do not", "exclude", "no", "not", "without"}
|
|
135
|
+
|
|
136
|
+
def semantic_text_key(value: Any) -> str:
|
|
137
|
+
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def text_tokens(value: Any) -> set[str]:
|
|
141
|
+
return {
|
|
142
|
+
token
|
|
143
|
+
for token in re.findall(r"[a-z0-9]+", str(value or "").lower().replace("_", " "))
|
|
144
|
+
if len(token) > 1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ordered_text_tokens(value: Any) -> list[str]:
|
|
149
|
+
return [
|
|
150
|
+
token
|
|
151
|
+
for token in re.findall(r"[a-z0-9]+", str(value or "").lower().replace("_", " "))
|
|
152
|
+
if len(token) > 1
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def content_tokens(value: Any) -> set[str]:
|
|
157
|
+
return {
|
|
158
|
+
token
|
|
159
|
+
for token in text_tokens(value)
|
|
160
|
+
if token not in GENERIC_CATALOG_TOKENS
|
|
161
|
+
and token not in TEMPORAL_REFERENCE_TOKENS
|
|
162
|
+
and not re.fullmatch(r"(?:19|20)\d{2}", token)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def runtime_customization_for(capability_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
167
|
+
if not isinstance(capability_metadata, dict):
|
|
168
|
+
return {}
|
|
169
|
+
direct = capability_metadata.get("runtime_customization")
|
|
170
|
+
if isinstance(direct, dict):
|
|
171
|
+
return direct
|
|
172
|
+
app_profile = capability_metadata.get("app_profile")
|
|
173
|
+
if isinstance(app_profile, dict) and isinstance(app_profile.get("runtime_customization"), dict):
|
|
174
|
+
return app_profile["runtime_customization"]
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalization_customization(customization: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
179
|
+
if not isinstance(customization, dict):
|
|
180
|
+
return {}
|
|
181
|
+
value = customization.get("normalization")
|
|
182
|
+
return value if isinstance(value, dict) else {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _configured_deictic_terms(customization: dict[str, Any] | None = None) -> set[str]:
|
|
186
|
+
terms = set(DEICTIC_REFERENCE_KEYS)
|
|
187
|
+
raw_terms = _normalization_customization(customization).get("deictic_terms")
|
|
188
|
+
if isinstance(raw_terms, list):
|
|
189
|
+
terms.update(semantic_text_key(item) for item in raw_terms if str(item or "").strip())
|
|
190
|
+
return terms
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _configured_token_variant_rules(customization: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
194
|
+
raw_rules = _normalization_customization(customization).get("token_variant_rules")
|
|
195
|
+
return [rule for rule in raw_rules if isinstance(rule, dict)] if isinstance(raw_rules, list) else []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_token_variant_rules(tokens: set[str], customization: dict[str, Any] | None = None) -> set[str]:
|
|
199
|
+
variants = set(tokens)
|
|
200
|
+
for token in list(tokens):
|
|
201
|
+
for rule in _configured_token_variant_rules(customization):
|
|
202
|
+
suffix = str(rule.get("suffix") or "").strip().lower()
|
|
203
|
+
replacement = str(rule.get("replacement") or "")
|
|
204
|
+
try:
|
|
205
|
+
min_length = int(rule.get("min_length") or 0)
|
|
206
|
+
except (TypeError, ValueError):
|
|
207
|
+
min_length = 0
|
|
208
|
+
if not suffix or len(token) < min_length or not token.endswith(suffix):
|
|
209
|
+
continue
|
|
210
|
+
variants.add(f"{token[:-len(suffix)]}{replacement}")
|
|
211
|
+
return variants
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _apply_basic_inflection_variants(tokens: set[str]) -> set[str]:
|
|
215
|
+
variants = set(tokens)
|
|
216
|
+
for token in list(tokens):
|
|
217
|
+
if len(token) <= 3:
|
|
218
|
+
continue
|
|
219
|
+
if token.endswith("ies") and len(token) > 4:
|
|
220
|
+
variants.add(f"{token[:-3]}y")
|
|
221
|
+
if token.endswith("es") and len(token) > 4:
|
|
222
|
+
variants.add(token[:-2])
|
|
223
|
+
if token.endswith("s") and len(token) > 4:
|
|
224
|
+
variants.add(token[:-1])
|
|
225
|
+
else:
|
|
226
|
+
variants.add(f"{token}s")
|
|
227
|
+
return variants
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _capability_selection_customization(customization: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
231
|
+
if not isinstance(customization, dict):
|
|
232
|
+
return {}
|
|
233
|
+
value = customization.get("capability_selection")
|
|
234
|
+
return value if isinstance(value, dict) else {}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _runtime_capability_selection_for(capability_metadata: dict[str, Any]) -> dict[str, Any]:
|
|
238
|
+
return _capability_selection_customization(runtime_customization_for(capability_metadata))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _list_of_dicts(value: Any) -> list[dict[str, Any]]:
|
|
242
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _capability_id_for_metadata(capability_metadata: dict[str, Any]) -> str:
|
|
246
|
+
return str(capability_metadata.get("capability_id") or capability_metadata.get("id") or "").strip()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _rule_applies_to_capability(rule: dict[str, Any], capability_id: str) -> bool:
|
|
250
|
+
rule_capability = str(rule.get("capability") or rule.get("capability_id") or "").strip()
|
|
251
|
+
return not rule_capability or not capability_id or rule_capability == capability_id
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def runtime_business_language_rules_for(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
|
255
|
+
capability_id = _capability_id_for_metadata(capability_metadata)
|
|
256
|
+
rules = _list_of_dicts(_runtime_capability_selection_for(capability_metadata).get("business_language_rules"))
|
|
257
|
+
return [rule for rule in rules if _rule_applies_to_capability(rule, capability_id)]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def runtime_selection_hints_for(metadata: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
261
|
+
hints: list[dict[str, Any]] = []
|
|
262
|
+
seen: set[str] = set()
|
|
263
|
+
for capability_metadata in metadata.values():
|
|
264
|
+
for hint in _list_of_dicts(_runtime_capability_selection_for(capability_metadata).get("selection_hints")):
|
|
265
|
+
key = repr(sorted(hint.items()))
|
|
266
|
+
if key in seen:
|
|
267
|
+
continue
|
|
268
|
+
seen.add(key)
|
|
269
|
+
hints.append(hint)
|
|
270
|
+
return hints
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _configured_float(
|
|
274
|
+
customization: dict[str, Any] | None,
|
|
275
|
+
key: str,
|
|
276
|
+
default: float,
|
|
277
|
+
) -> float:
|
|
278
|
+
raw_value = _capability_selection_customization(customization).get(key)
|
|
279
|
+
try:
|
|
280
|
+
return float(raw_value)
|
|
281
|
+
except (TypeError, ValueError):
|
|
282
|
+
return default
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def content_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
|
|
286
|
+
variants = set(content_tokens(value))
|
|
287
|
+
variants = _apply_basic_inflection_variants(variants)
|
|
288
|
+
return _apply_token_variant_rules(variants, customization)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def capability_score_tokens(value: Any) -> set[str]:
|
|
292
|
+
return {
|
|
293
|
+
token
|
|
294
|
+
for token in text_tokens(value)
|
|
295
|
+
if token not in CAPABILITY_SCORING_STOP_TOKENS
|
|
296
|
+
and not re.fullmatch(r"(?:19|20)\d{2}", token)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def capability_score_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
|
|
301
|
+
variants = set(capability_score_tokens(value))
|
|
302
|
+
variants = _apply_basic_inflection_variants(variants)
|
|
303
|
+
return _apply_token_variant_rules(variants, customization)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def exact_value_token_variants(value: Any, customization: dict[str, Any] | None = None) -> set[str]:
|
|
307
|
+
variants = text_tokens(value)
|
|
308
|
+
return _apply_token_variant_rules(variants, customization)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def temporal_tokens(value: Any) -> set[str]:
|
|
312
|
+
return text_tokens(value) & TEMPORAL_REFERENCE_TOKENS
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def candidate_temporal_context_is_supported(candidate_text: str, source_text: str) -> bool:
|
|
316
|
+
candidate_temporal = temporal_tokens(candidate_text)
|
|
317
|
+
if not candidate_temporal:
|
|
318
|
+
return True
|
|
319
|
+
source_temporal = temporal_tokens(source_text)
|
|
320
|
+
if candidate_temporal & source_temporal:
|
|
321
|
+
return True
|
|
322
|
+
return quarter_label_from_text(source_text) is not None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def token_score(candidate_text: str, source_text: str) -> float:
|
|
326
|
+
candidate_tokens = text_tokens(candidate_text)
|
|
327
|
+
source_tokens = text_tokens(source_text)
|
|
328
|
+
if not candidate_tokens or not source_tokens:
|
|
329
|
+
return 0.0
|
|
330
|
+
overlap = candidate_tokens & source_tokens
|
|
331
|
+
if not overlap:
|
|
332
|
+
return 0.0
|
|
333
|
+
return len(overlap) / len(candidate_tokens)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def conversation_contains_value(conversation: str, value: Any) -> bool:
|
|
337
|
+
if not isinstance(value, str):
|
|
338
|
+
return True
|
|
339
|
+
raw_value = value.strip()
|
|
340
|
+
if not raw_value:
|
|
341
|
+
return False
|
|
342
|
+
if raw_value.lower() in conversation.lower():
|
|
343
|
+
return True
|
|
344
|
+
return semantic_text_key(raw_value) in semantic_text_key(conversation)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def is_deictic_reference(value: Any) -> bool:
|
|
348
|
+
return semantic_text_key(value) in DEICTIC_REFERENCE_KEYS
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def contains_deictic_reference(value: Any, customization: dict[str, Any] | None = None) -> bool:
|
|
352
|
+
tokens = text_tokens(value)
|
|
353
|
+
deictic_terms = _configured_deictic_terms(customization)
|
|
354
|
+
return bool({semantic_text_key(token) for token in tokens} & deictic_terms) or semantic_text_key(value) in deictic_terms
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def input_semantics_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
|
|
358
|
+
items = capability_metadata.get("input_semantics")
|
|
359
|
+
if not isinstance(items, list):
|
|
360
|
+
return {}
|
|
361
|
+
for item in items:
|
|
362
|
+
if isinstance(item, dict) and str(item.get("input_name") or "") == input_name:
|
|
363
|
+
return item
|
|
364
|
+
return {}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def input_resolution_for(
|
|
368
|
+
capability_metadata: dict[str, Any],
|
|
369
|
+
input_name: str,
|
|
370
|
+
input_spec: dict[str, Any] | None = None,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
"""Return v0.24 input-resolution metadata from the strongest available source."""
|
|
373
|
+
if isinstance(input_spec, dict) and isinstance(input_spec.get("resolution"), dict):
|
|
374
|
+
return input_spec["resolution"]
|
|
375
|
+
semantics = input_semantics_for(capability_metadata, input_name)
|
|
376
|
+
if isinstance(semantics.get("resolution"), dict):
|
|
377
|
+
return semantics["resolution"]
|
|
378
|
+
for item in capability_metadata.get("input_specs") or []:
|
|
379
|
+
if isinstance(item, dict) and str(item.get("name") or item.get("input_name") or "") == input_name:
|
|
380
|
+
if isinstance(item.get("resolution"), dict):
|
|
381
|
+
return item["resolution"]
|
|
382
|
+
return {}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def input_resolution_mode_for(
|
|
386
|
+
capability_metadata: dict[str, Any],
|
|
387
|
+
input_name: str,
|
|
388
|
+
input_spec: dict[str, Any] | None = None,
|
|
389
|
+
) -> str:
|
|
390
|
+
return str(input_resolution_for(capability_metadata, input_name, input_spec).get("mode") or "")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def input_meanings_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
|
|
394
|
+
meanings = capability_metadata.get("app_profile", {}).get("input_meanings")
|
|
395
|
+
if not isinstance(meanings, dict):
|
|
396
|
+
return {}
|
|
397
|
+
value = meanings.get(input_name)
|
|
398
|
+
return value if isinstance(value, dict) else {}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def reference_catalog_for(capability_metadata: dict[str, Any], input_name: str) -> list[str]:
|
|
402
|
+
catalogs = capability_metadata.get("app_profile", {}).get("reference_catalogs")
|
|
403
|
+
if not isinstance(catalogs, dict):
|
|
404
|
+
return []
|
|
405
|
+
values = catalogs.get(input_name)
|
|
406
|
+
if not isinstance(values, list):
|
|
407
|
+
return []
|
|
408
|
+
return [str(item) for item in values if str(item or "").strip()]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def required_context_for(capability_metadata: dict[str, Any], input_name: str) -> dict[str, Any]:
|
|
412
|
+
items = capability_metadata.get("app_profile", {}).get("required_context") or capability_metadata.get("required_context")
|
|
413
|
+
if not isinstance(items, list):
|
|
414
|
+
return {}
|
|
415
|
+
for item in items:
|
|
416
|
+
if isinstance(item, dict) and str(item.get("input") or "") == input_name:
|
|
417
|
+
return item
|
|
418
|
+
return {}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def candidate_map_for_input(capability_metadata: dict[str, Any], input_name: str) -> dict[str, str]:
|
|
422
|
+
candidates = {
|
|
423
|
+
str(candidate): str(meaning)
|
|
424
|
+
for candidate, meaning in input_meanings_for(capability_metadata, input_name).items()
|
|
425
|
+
}
|
|
426
|
+
for catalog_value in reference_catalog_for(capability_metadata, input_name):
|
|
427
|
+
candidates.setdefault(catalog_value, catalog_value.replace("_", " "))
|
|
428
|
+
return candidates
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def semantic_candidate_values_for_input(capability_metadata: dict[str, Any], input_name: str) -> dict[str, str]:
|
|
432
|
+
candidates: dict[str, str] = {}
|
|
433
|
+
for item in capability_metadata.get("input_semantics") or []:
|
|
434
|
+
if not isinstance(item, dict) or str(item.get("input_name") or "") != input_name:
|
|
435
|
+
continue
|
|
436
|
+
for allowed in item.get("allowed_values") or []:
|
|
437
|
+
if not isinstance(allowed, dict):
|
|
438
|
+
continue
|
|
439
|
+
value = str(allowed.get("value") or "").strip()
|
|
440
|
+
if value:
|
|
441
|
+
candidates.setdefault(value, str(allowed.get("meaning") or value))
|
|
442
|
+
app_profile = capability_metadata.get("app_profile")
|
|
443
|
+
if isinstance(app_profile, dict):
|
|
444
|
+
for item in app_profile.get("input_semantics") or []:
|
|
445
|
+
if not isinstance(item, dict) or str(item.get("input_name") or "") != input_name:
|
|
446
|
+
continue
|
|
447
|
+
for allowed in item.get("allowed_values") or []:
|
|
448
|
+
if not isinstance(allowed, dict):
|
|
449
|
+
continue
|
|
450
|
+
value = str(allowed.get("value") or "").strip()
|
|
451
|
+
if value:
|
|
452
|
+
candidates.setdefault(value, str(allowed.get("meaning") or value))
|
|
453
|
+
return candidates
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def all_candidate_values_for_input(capability_metadata: dict[str, Any], input_spec: dict[str, Any]) -> dict[str, str]:
|
|
457
|
+
input_name = str(input_spec.get("name") or "")
|
|
458
|
+
candidates = candidate_map_for_input(capability_metadata, input_name)
|
|
459
|
+
candidates.update(semantic_candidate_values_for_input(capability_metadata, input_name))
|
|
460
|
+
if not candidates:
|
|
461
|
+
candidates.update({allowed: allowed.replace("_", " ") for allowed in declared_input_candidate_values(input_spec)})
|
|
462
|
+
return candidates
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def value_matches_other_declared_input(
|
|
466
|
+
value: Any,
|
|
467
|
+
input_name: str,
|
|
468
|
+
capability_metadata: dict[str, Any],
|
|
469
|
+
) -> bool:
|
|
470
|
+
if not isinstance(value, str) or not value.strip():
|
|
471
|
+
return False
|
|
472
|
+
value_key = semantic_text_key(value)
|
|
473
|
+
if not value_key:
|
|
474
|
+
return False
|
|
475
|
+
for input_spec in capability_metadata.get("input_specs") or []:
|
|
476
|
+
if not isinstance(input_spec, dict):
|
|
477
|
+
continue
|
|
478
|
+
other_name = str(input_spec.get("name") or "")
|
|
479
|
+
if not other_name or other_name == input_name:
|
|
480
|
+
continue
|
|
481
|
+
for candidate in all_candidate_values_for_input(capability_metadata, input_spec):
|
|
482
|
+
if semantic_text_key(candidate) == value_key:
|
|
483
|
+
return True
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def declared_input_candidate_values(input_spec: dict[str, Any]) -> list[str]:
|
|
488
|
+
allowed_values = [str(value) for value in input_spec.get("allowed_values") or [] if str(value or "").strip()]
|
|
489
|
+
if allowed_values:
|
|
490
|
+
return allowed_values
|
|
491
|
+
|
|
492
|
+
description = str(input_spec.get("description") or "")
|
|
493
|
+
default = input_spec.get("default")
|
|
494
|
+
if not isinstance(default, str) or not default.strip() or "," not in description:
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
candidates: list[str] = []
|
|
498
|
+
for raw_part in re.split(r",|\bor\b", description):
|
|
499
|
+
candidate = raw_part.strip().strip(".;:")
|
|
500
|
+
if not candidate:
|
|
501
|
+
continue
|
|
502
|
+
if len(text_tokens(candidate)) > 3:
|
|
503
|
+
continue
|
|
504
|
+
if "_" in candidate or semantic_text_key(candidate) == semantic_text_key(default):
|
|
505
|
+
candidates.append(candidate.replace(" ", "_"))
|
|
506
|
+
|
|
507
|
+
deduped: list[str] = []
|
|
508
|
+
for candidate in candidates:
|
|
509
|
+
if candidate not in deduped:
|
|
510
|
+
deduped.append(candidate)
|
|
511
|
+
return deduped
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def canonical_from_candidates(
|
|
515
|
+
value: str,
|
|
516
|
+
conversation: str,
|
|
517
|
+
candidates: dict[str, str],
|
|
518
|
+
customization: dict[str, Any] | None = None,
|
|
519
|
+
) -> str | None:
|
|
520
|
+
raw_value = value.strip()
|
|
521
|
+
if not raw_value or not candidates:
|
|
522
|
+
return None
|
|
523
|
+
normalized_value = semantic_text_key(raw_value)
|
|
524
|
+
for candidate in candidates:
|
|
525
|
+
if normalized_value == semantic_text_key(candidate):
|
|
526
|
+
return candidate
|
|
527
|
+
|
|
528
|
+
source_text = f"{raw_value}\n{conversation}"
|
|
529
|
+
best_candidate = None
|
|
530
|
+
best_score = 0.0
|
|
531
|
+
second_best_score = 0.0
|
|
532
|
+
value_tokens = content_token_variants(raw_value, customization)
|
|
533
|
+
if not value_tokens:
|
|
534
|
+
return None
|
|
535
|
+
for candidate, meaning in candidates.items():
|
|
536
|
+
candidate_text = f"{candidate} {meaning}"
|
|
537
|
+
if not candidate_temporal_context_is_supported(candidate_text, source_text):
|
|
538
|
+
continue
|
|
539
|
+
candidate_tokens = content_token_variants(candidate_text, customization)
|
|
540
|
+
if not (value_tokens & candidate_tokens):
|
|
541
|
+
continue
|
|
542
|
+
value_score = (len(value_tokens & candidate_tokens) / len(value_tokens)) if value_tokens else 0.0
|
|
543
|
+
score = max(token_score(candidate, raw_value), token_score(candidate_text, source_text), value_score)
|
|
544
|
+
if score > best_score:
|
|
545
|
+
second_best_score = best_score
|
|
546
|
+
best_candidate = candidate
|
|
547
|
+
best_score = score
|
|
548
|
+
elif score > second_best_score:
|
|
549
|
+
second_best_score = score
|
|
550
|
+
|
|
551
|
+
if best_candidate is None:
|
|
552
|
+
return None
|
|
553
|
+
if best_score >= 0.5:
|
|
554
|
+
return best_candidate
|
|
555
|
+
# Short business phrases often provide one discriminating token ("sales",
|
|
556
|
+
# "SDR", "webinar"). Accept that only when it is unambiguous.
|
|
557
|
+
return best_candidate if best_score >= 0.3 and second_best_score == 0.0 else None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def conversation_supports_canonical_value(
|
|
561
|
+
conversation: str,
|
|
562
|
+
value: str,
|
|
563
|
+
candidates: dict[str, str],
|
|
564
|
+
customization: dict[str, Any] | None = None,
|
|
565
|
+
) -> bool:
|
|
566
|
+
if contains_deictic_reference(value, customization):
|
|
567
|
+
return False
|
|
568
|
+
if conversation_contains_value(conversation, value):
|
|
569
|
+
return True
|
|
570
|
+
if contains_deictic_reference(conversation, customization):
|
|
571
|
+
return False
|
|
572
|
+
return canonical_from_candidates(conversation, conversation, candidates, customization) == value
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def normalize_reference_value(
|
|
576
|
+
input_spec: dict[str, Any],
|
|
577
|
+
value: Any,
|
|
578
|
+
conversation: str,
|
|
579
|
+
capability_metadata: dict[str, Any],
|
|
580
|
+
) -> Any:
|
|
581
|
+
if not isinstance(value, str) or not value.strip():
|
|
582
|
+
return value
|
|
583
|
+
|
|
584
|
+
input_name = str(input_spec.get("name") or "")
|
|
585
|
+
customization = runtime_customization_for(capability_metadata)
|
|
586
|
+
if contains_deictic_reference(value, customization):
|
|
587
|
+
return value
|
|
588
|
+
|
|
589
|
+
normalized = canonical_from_candidates(
|
|
590
|
+
value,
|
|
591
|
+
conversation,
|
|
592
|
+
candidate_map_for_input(capability_metadata, input_name),
|
|
593
|
+
customization,
|
|
594
|
+
)
|
|
595
|
+
return normalized if normalized is not None else value
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def looks_like_quarter_input(input_spec: dict[str, Any]) -> bool:
|
|
599
|
+
name = str(input_spec.get("name") or "").strip().lower()
|
|
600
|
+
description = str(input_spec.get("description") or "").strip().lower()
|
|
601
|
+
return name in {"quarter", "fiscal_quarter"} or "quarter label" in description or "yyyy-q" in description
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def quarter_label_from_text(text: str) -> str | None:
|
|
605
|
+
raw = str(text or "")
|
|
606
|
+
if not raw.strip():
|
|
607
|
+
return None
|
|
608
|
+
year_first = re.search(r"\b((?:19|20)\d{2})\s*[-_/ ]?\s*[Qq]([1-4])\b", raw)
|
|
609
|
+
if year_first:
|
|
610
|
+
return f"{year_first.group(1)}-Q{year_first.group(2)}"
|
|
611
|
+
quarter_first = re.search(r"\b[Qq]([1-4])\s*(?:FY\s*)?((?:19|20)\d{2})\b", raw)
|
|
612
|
+
if quarter_first:
|
|
613
|
+
return f"{quarter_first.group(2)}-Q{quarter_first.group(1)}"
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def normalize_declared_input_value(input_spec: dict[str, Any], value: Any, conversation: str) -> Any:
|
|
618
|
+
if not looks_like_quarter_input(input_spec) or not isinstance(value, str):
|
|
619
|
+
return value
|
|
620
|
+
return quarter_label_from_text(value) or quarter_label_from_text(conversation) or value
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def is_supported_quarter_input_value(input_spec: dict[str, Any], value: Any, conversation: str) -> bool:
|
|
624
|
+
if not looks_like_quarter_input(input_spec) or not isinstance(value, str):
|
|
625
|
+
return True
|
|
626
|
+
return quarter_label_from_text(conversation) is not None
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def infer_declared_input_value(
|
|
630
|
+
input_spec: dict[str, Any],
|
|
631
|
+
conversation: str,
|
|
632
|
+
capability_metadata: dict[str, Any],
|
|
633
|
+
) -> Any:
|
|
634
|
+
if looks_like_quarter_input(input_spec):
|
|
635
|
+
explicit_quarter = quarter_label_from_text(conversation)
|
|
636
|
+
if explicit_quarter is not None:
|
|
637
|
+
return explicit_quarter
|
|
638
|
+
input_name = str(input_spec.get("name") or "")
|
|
639
|
+
customization = runtime_customization_for(capability_metadata)
|
|
640
|
+
candidates = all_candidate_values_for_input(capability_metadata, input_spec)
|
|
641
|
+
if looks_like_quarter_input(input_spec) and not candidates:
|
|
642
|
+
return None
|
|
643
|
+
if (
|
|
644
|
+
not looks_like_quarter_input(input_spec)
|
|
645
|
+
and contains_deictic_reference(conversation, customization)
|
|
646
|
+
and requires_declared_grounding(input_spec, capability_metadata)
|
|
647
|
+
):
|
|
648
|
+
conversation_tokens = content_token_variants(conversation, customization)
|
|
649
|
+
candidates = {
|
|
650
|
+
candidate: meaning
|
|
651
|
+
for candidate, meaning in candidates.items()
|
|
652
|
+
if content_token_variants(candidate, customization) & conversation_tokens
|
|
653
|
+
}
|
|
654
|
+
normalized = canonical_from_candidates(conversation, conversation, candidates, customization)
|
|
655
|
+
if normalized is not None:
|
|
656
|
+
return normalized
|
|
657
|
+
|
|
658
|
+
# For reviewed enum/allowed values, the value identifiers themselves are
|
|
659
|
+
# part of the contract. Allow a unique exact-token match even when the token
|
|
660
|
+
# is too generic for free-form catalog matching, e.g. account_risk from
|
|
661
|
+
# "account reassignment".
|
|
662
|
+
conversation_tokens = exact_value_token_variants(conversation, customization)
|
|
663
|
+
exact_matches = [
|
|
664
|
+
candidate
|
|
665
|
+
for candidate in candidates
|
|
666
|
+
if exact_value_token_variants(candidate, customization) & conversation_tokens
|
|
667
|
+
and candidate_temporal_context_is_supported(f"{candidate} {candidates.get(candidate, '')}", conversation)
|
|
668
|
+
]
|
|
669
|
+
if len(exact_matches) == 1:
|
|
670
|
+
return exact_matches[0]
|
|
671
|
+
input_name = str(input_spec.get("name") or "")
|
|
672
|
+
has_reviewed_candidates = bool(
|
|
673
|
+
input_meanings_for(capability_metadata, input_name)
|
|
674
|
+
or reference_catalog_for(capability_metadata, input_name)
|
|
675
|
+
)
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def requires_declared_grounding(input_spec: dict[str, Any], capability_metadata: dict[str, Any]) -> bool:
|
|
680
|
+
input_name = str(input_spec.get("name") or "")
|
|
681
|
+
input_description = str(input_spec.get("description") or "")
|
|
682
|
+
if not input_name or input_spec.get("default") is not None:
|
|
683
|
+
return False
|
|
684
|
+
resolution_mode = input_resolution_mode_for(capability_metadata, input_name, input_spec)
|
|
685
|
+
if resolution_mode:
|
|
686
|
+
return resolution_mode in {
|
|
687
|
+
"closed_values",
|
|
688
|
+
"backend_resolved",
|
|
689
|
+
"app_selected",
|
|
690
|
+
"actor_policy",
|
|
691
|
+
"actor_policy_or_explicit",
|
|
692
|
+
"explicit_only",
|
|
693
|
+
"clarify",
|
|
694
|
+
}
|
|
695
|
+
input_key = semantic_text_key(f"{input_name} {input_description}")
|
|
696
|
+
semantics = input_semantics_for(capability_metadata, input_name)
|
|
697
|
+
required_context = required_context_for(capability_metadata, input_name)
|
|
698
|
+
input_meanings = input_meanings_for(capability_metadata, input_name)
|
|
699
|
+
reference_catalog = reference_catalog_for(capability_metadata, input_name)
|
|
700
|
+
missing_behavior = str(required_context.get("missing_behavior") or "")
|
|
701
|
+
semantic_type = str(semantics.get("semantic_type") or "")
|
|
702
|
+
if input_meanings or reference_catalog:
|
|
703
|
+
return True
|
|
704
|
+
if any(marker in input_key for marker in ("reference", "target", "entity", "subject")) or input_name.endswith("_ref"):
|
|
705
|
+
return True
|
|
706
|
+
if semantic_type.endswith("_reference") or semantic_type in {"entity_reference", "business_context"}:
|
|
707
|
+
return True
|
|
708
|
+
return missing_behavior in {"clarify", "clarify_or_app_select"}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def allows_open_text_reference_inference(input_spec: dict[str, Any], capability_metadata: dict[str, Any]) -> bool:
|
|
712
|
+
"""Allow open-text inference only for fields that are explicitly reference-like."""
|
|
713
|
+
input_name = str(input_spec.get("name") or "")
|
|
714
|
+
resolution_mode = input_resolution_mode_for(capability_metadata, input_name, input_spec)
|
|
715
|
+
if resolution_mode:
|
|
716
|
+
return resolution_mode == "backend_resolved"
|
|
717
|
+
input_description = str(input_spec.get("description") or "")
|
|
718
|
+
input_key = semantic_text_key(f"{input_name} {input_description}")
|
|
719
|
+
semantics = input_semantics_for(capability_metadata, input_name)
|
|
720
|
+
semantic_type = str(semantics.get("semantic_type") or "")
|
|
721
|
+
if any(marker in input_key for marker in ("reference", "target", "entity", "subject")) or input_name.endswith("_ref"):
|
|
722
|
+
return True
|
|
723
|
+
return (semantic_type.endswith("_reference") and semantic_type != "scope_reference") or semantic_type == "entity_reference"
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def is_ungrounded_declared_context(
|
|
727
|
+
input_spec: dict[str, Any],
|
|
728
|
+
value: Any,
|
|
729
|
+
conversation: str,
|
|
730
|
+
capability_metadata: dict[str, Any],
|
|
731
|
+
) -> bool:
|
|
732
|
+
if not isinstance(value, str) or not requires_declared_grounding(input_spec, capability_metadata):
|
|
733
|
+
return False
|
|
734
|
+
customization = runtime_customization_for(capability_metadata)
|
|
735
|
+
if contains_deictic_reference(value, customization):
|
|
736
|
+
return True
|
|
737
|
+
input_name = str(input_spec.get("name") or "")
|
|
738
|
+
candidates = candidate_map_for_input(capability_metadata, input_name)
|
|
739
|
+
if not candidates:
|
|
740
|
+
if not allows_open_text_reference_inference(input_spec, capability_metadata):
|
|
741
|
+
return True
|
|
742
|
+
if value_matches_other_declared_input(value, input_name, capability_metadata):
|
|
743
|
+
return True
|
|
744
|
+
# Open-ended entity/reference inputs are validated by the service or
|
|
745
|
+
# backend. The app should pass concrete names through instead of
|
|
746
|
+
# requiring Studio to enumerate every possible business entity.
|
|
747
|
+
return not looks_like_concrete_reference_value(value)
|
|
748
|
+
if conversation_supports_canonical_value(conversation, value, candidates, customization):
|
|
749
|
+
return False
|
|
750
|
+
return True
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def looks_like_concrete_reference_value(value: Any) -> bool:
|
|
754
|
+
if not isinstance(value, str):
|
|
755
|
+
return True
|
|
756
|
+
raw_value = value.strip()
|
|
757
|
+
if not raw_value:
|
|
758
|
+
return False
|
|
759
|
+
lowered = raw_value.lower()
|
|
760
|
+
vague_phrases = {
|
|
761
|
+
"best customer",
|
|
762
|
+
"top account",
|
|
763
|
+
"top customer",
|
|
764
|
+
"best account",
|
|
765
|
+
"selected account",
|
|
766
|
+
"recommended account",
|
|
767
|
+
"highest priority account",
|
|
768
|
+
"our best customer",
|
|
769
|
+
"our top account",
|
|
770
|
+
}
|
|
771
|
+
if lowered in vague_phrases:
|
|
772
|
+
return False
|
|
773
|
+
if contains_deictic_reference(raw_value):
|
|
774
|
+
return False
|
|
775
|
+
tokens = re.findall(r"[A-Za-z0-9]+", raw_value)
|
|
776
|
+
if not tokens:
|
|
777
|
+
return False
|
|
778
|
+
non_entity_tokens = {
|
|
779
|
+
"draft",
|
|
780
|
+
"east",
|
|
781
|
+
"find",
|
|
782
|
+
"for",
|
|
783
|
+
"north",
|
|
784
|
+
"prioritize",
|
|
785
|
+
"q1",
|
|
786
|
+
"q2",
|
|
787
|
+
"q3",
|
|
788
|
+
"q4",
|
|
789
|
+
"review",
|
|
790
|
+
"show",
|
|
791
|
+
"south",
|
|
792
|
+
"use",
|
|
793
|
+
"west",
|
|
794
|
+
}
|
|
795
|
+
if all(token.lower() in non_entity_tokens or re.fullmatch(r"(?:19|20)\d{2}", token) for token in tokens):
|
|
796
|
+
return False
|
|
797
|
+
if any(any(character.isupper() for character in token) for token in tokens):
|
|
798
|
+
return True
|
|
799
|
+
# Stable business identifiers such as account_123 or acme-corp are concrete
|
|
800
|
+
# even when they arrive lower-cased.
|
|
801
|
+
return bool(re.search(r"[_-]|\d", raw_value)) and len(content_tokens(raw_value)) >= 1
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def concrete_reference_value_from_text(text: str) -> str | None:
|
|
805
|
+
stop_terms = {
|
|
806
|
+
"A",
|
|
807
|
+
"An",
|
|
808
|
+
"And",
|
|
809
|
+
"CRM",
|
|
810
|
+
"CSV",
|
|
811
|
+
"East",
|
|
812
|
+
"Find",
|
|
813
|
+
"For",
|
|
814
|
+
"GTM",
|
|
815
|
+
"North",
|
|
816
|
+
"Q1",
|
|
817
|
+
"Q2",
|
|
818
|
+
"Q3",
|
|
819
|
+
"Q4",
|
|
820
|
+
"Route",
|
|
821
|
+
"Show",
|
|
822
|
+
"South",
|
|
823
|
+
"West",
|
|
824
|
+
}
|
|
825
|
+
for match in re.finditer(r"\b[A-Z][A-Za-z0-9]*(?:\s+[A-Z][A-Za-z0-9]*){0,3}\b", str(text or "")):
|
|
826
|
+
candidate = match.group(0).strip()
|
|
827
|
+
if not candidate or candidate in stop_terms:
|
|
828
|
+
continue
|
|
829
|
+
if re.fullmatch(r"Q[1-4]|FY\d{2,4}|\d{4}", candidate):
|
|
830
|
+
continue
|
|
831
|
+
if looks_like_concrete_reference_value(candidate):
|
|
832
|
+
return candidate
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def capability_produces(capability_metadata: dict[str, Any]) -> set[str]:
|
|
837
|
+
effects = capability_metadata.get("business_effects")
|
|
838
|
+
if not isinstance(effects, dict):
|
|
839
|
+
return set()
|
|
840
|
+
values = effects.get("produces")
|
|
841
|
+
return {str(item) for item in values} if isinstance(values, list) else set()
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def capability_does_not_produce(capability_metadata: dict[str, Any]) -> set[str]:
|
|
845
|
+
effects = capability_metadata.get("business_effects")
|
|
846
|
+
if not isinstance(effects, dict):
|
|
847
|
+
return set()
|
|
848
|
+
values = effects.get("does_not_produce")
|
|
849
|
+
blocked = {str(item) for item in values} if isinstance(values, list) else set()
|
|
850
|
+
app_boundaries = capability_metadata.get("app_profile", {}).get("app_boundaries")
|
|
851
|
+
if isinstance(app_boundaries, dict) and isinstance(app_boundaries.get("unsupported_effects"), list):
|
|
852
|
+
blocked.update(str(item) for item in app_boundaries["unsupported_effects"])
|
|
853
|
+
return blocked
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def effective_business_effects(*sources: dict[str, Any]) -> dict[str, Any]:
|
|
857
|
+
effects: dict[str, Any] = {}
|
|
858
|
+
for source in sources:
|
|
859
|
+
candidate = source.get("business_effects")
|
|
860
|
+
if isinstance(candidate, dict):
|
|
861
|
+
effects = dict(candidate)
|
|
862
|
+
break
|
|
863
|
+
if any(isinstance(source.get("grant_policy"), dict) for source in sources):
|
|
864
|
+
produces = {str(item) for item in effects.get("produces") or []}
|
|
865
|
+
does_not_produce = {str(item) for item in effects.get("does_not_produce") or []}
|
|
866
|
+
produces.discard("data.read")
|
|
867
|
+
produces.update({"approval.request", "system.preview_mutation"})
|
|
868
|
+
does_not_produce.add("approval.execute")
|
|
869
|
+
effects["produces"] = sorted(produces)
|
|
870
|
+
effects["does_not_produce"] = sorted(does_not_produce)
|
|
871
|
+
return effects
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def metadata_with_manifest_controls(profile_metadata: dict[str, Any], manifest_capability: dict[str, Any]) -> dict[str, Any]:
|
|
875
|
+
if not isinstance(manifest_capability.get("grant_policy"), dict):
|
|
876
|
+
return profile_metadata
|
|
877
|
+
metadata = dict(profile_metadata)
|
|
878
|
+
approval = dict(metadata.get("approval")) if isinstance(metadata.get("approval"), dict) else {}
|
|
879
|
+
approval.setdefault("required", True)
|
|
880
|
+
approval.setdefault("grant_types", manifest_capability["grant_policy"].get("allowed_grant_types") or [])
|
|
881
|
+
approval.setdefault("approval_effect", "approval.request")
|
|
882
|
+
metadata["approval"] = approval
|
|
883
|
+
app_boundaries = dict(metadata.get("app_boundaries")) if isinstance(metadata.get("app_boundaries"), dict) else {}
|
|
884
|
+
app_boundaries.setdefault(
|
|
885
|
+
"guidance",
|
|
886
|
+
"This capability is approval-governed. Invoke it to produce the service-owned preview/request; do not execute the governed action in app code.",
|
|
887
|
+
)
|
|
888
|
+
metadata["app_boundaries"] = app_boundaries
|
|
889
|
+
return metadata
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def compact_agent_json(value: Any) -> str:
|
|
893
|
+
"""Render compact deterministic JSON for prompt-safe metadata fragments."""
|
|
894
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def render_agent_input_spec(item: dict[str, Any]) -> str:
|
|
898
|
+
name = str(item.get("name") or "").strip()
|
|
899
|
+
if not name:
|
|
900
|
+
return ""
|
|
901
|
+
fragments = [name]
|
|
902
|
+
description = str(item.get("description") or "").strip()
|
|
903
|
+
input_type = str(item.get("type") or "").strip()
|
|
904
|
+
if input_type:
|
|
905
|
+
fragments.append(f"type={input_type}")
|
|
906
|
+
if item.get("required") is not None:
|
|
907
|
+
fragments.append(f"required={bool(item.get('required'))}")
|
|
908
|
+
if item.get("default") is not None:
|
|
909
|
+
fragments.append(f"default={item.get('default')}")
|
|
910
|
+
allowed_values = item.get("allowed_values")
|
|
911
|
+
if isinstance(allowed_values, list) and allowed_values:
|
|
912
|
+
fragments.append(f"allowed={','.join(str(value) for value in allowed_values)}")
|
|
913
|
+
if description:
|
|
914
|
+
fragments.append(f"description={description}")
|
|
915
|
+
return f"{name}[{' | '.join(fragments[1:])}]" if len(fragments) > 1 else name
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def render_agent_business_effects(effects: dict[str, Any]) -> str:
|
|
919
|
+
if not effects:
|
|
920
|
+
return ""
|
|
921
|
+
fragments: list[str] = []
|
|
922
|
+
for key in ("produces", "does_not_produce"):
|
|
923
|
+
values = effects.get(key)
|
|
924
|
+
if isinstance(values, list) and values:
|
|
925
|
+
fragments.append(f"{key}={','.join(str(value) for value in values)}")
|
|
926
|
+
return " | business_effects=" + "; ".join(fragments) if fragments else ""
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _render_agent_profile_fragment(metadata: dict[str, Any], *, keys: tuple[str, ...]) -> str:
|
|
930
|
+
fragments: list[str] = []
|
|
931
|
+
framing = str(metadata.get("capability_framing") or "").strip()
|
|
932
|
+
if framing:
|
|
933
|
+
fragments.append(f"capability_framing={framing}")
|
|
934
|
+
for key in keys:
|
|
935
|
+
value = metadata.get(key)
|
|
936
|
+
if isinstance(value, (dict, list)) and value:
|
|
937
|
+
fragments.append(f"{key}={compact_agent_json(value)}")
|
|
938
|
+
return " | app_profile=" + " ; ".join(fragments) if fragments else ""
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def render_agent_routing_metadata(metadata: dict[str, Any]) -> str:
|
|
942
|
+
"""Render metadata useful for capability selection without dumping full app profile state."""
|
|
943
|
+
return _render_agent_profile_fragment(
|
|
944
|
+
metadata,
|
|
945
|
+
keys=(
|
|
946
|
+
"app_boundaries",
|
|
947
|
+
"approval",
|
|
948
|
+
"required_context",
|
|
949
|
+
"app_glue",
|
|
950
|
+
"derived_target_owner",
|
|
951
|
+
),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def render_agent_detail_metadata(metadata: dict[str, Any]) -> str:
|
|
956
|
+
"""Render full per-capability planning metadata for optional second-stage prompts/debugging."""
|
|
957
|
+
return _render_agent_profile_fragment(
|
|
958
|
+
metadata,
|
|
959
|
+
keys=(
|
|
960
|
+
"input_meanings",
|
|
961
|
+
"input_semantics",
|
|
962
|
+
"reference_catalogs",
|
|
963
|
+
"result_display",
|
|
964
|
+
"app_boundaries",
|
|
965
|
+
"approval",
|
|
966
|
+
"required_context",
|
|
967
|
+
"app_glue",
|
|
968
|
+
"derived_target_owner",
|
|
969
|
+
"intent_rules",
|
|
970
|
+
"business_language_rules",
|
|
971
|
+
),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def render_compact_agent_input_summary(input_specs: list[Any]) -> str:
|
|
976
|
+
"""Render input specs for compact routing prompts.
|
|
977
|
+
|
|
978
|
+
This is intentionally smaller than `render_agent_input_spec`: compact
|
|
979
|
+
profile prompts need enough structure for routing and obvious parameter
|
|
980
|
+
binding, while full contract validation still happens against the complete
|
|
981
|
+
metadata after selection.
|
|
982
|
+
"""
|
|
983
|
+
rendered: list[str] = []
|
|
984
|
+
for raw_spec in input_specs:
|
|
985
|
+
if not isinstance(raw_spec, dict):
|
|
986
|
+
continue
|
|
987
|
+
name = str(raw_spec.get("name") or "").strip()
|
|
988
|
+
if not name:
|
|
989
|
+
continue
|
|
990
|
+
marker = "req" if raw_spec.get("required") is True else "opt"
|
|
991
|
+
allowed = raw_spec.get("allowed_values")
|
|
992
|
+
allowed_text = ""
|
|
993
|
+
if isinstance(allowed, list) and allowed:
|
|
994
|
+
allowed_text = f"={ '/'.join(str(item) for item in allowed[:8]) }"
|
|
995
|
+
rendered.append(f"{name}({marker}{allowed_text})")
|
|
996
|
+
return ", ".join(rendered) or "none"
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _metadata_effect_values(metadata: dict[str, Any], key: str) -> list[str]:
|
|
1000
|
+
effects = metadata.get("business_effects")
|
|
1001
|
+
values = effects.get(key) if isinstance(effects, dict) else None
|
|
1002
|
+
return [str(item) for item in values if str(item).strip()] if isinstance(values, list) else []
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def render_compact_agent_capability_line(capability_id: str, metadata: dict[str, Any]) -> str:
|
|
1006
|
+
"""Render one compact capability candidate line for model routing."""
|
|
1007
|
+
produces = _metadata_effect_values(metadata, "produces")
|
|
1008
|
+
forbidden = _metadata_effect_values(metadata, "does_not_produce")
|
|
1009
|
+
grant_policy = metadata.get("grant_policy")
|
|
1010
|
+
approval = " approval" if isinstance(grant_policy, dict) and grant_policy else ""
|
|
1011
|
+
input_specs = metadata.get("input_specs") if isinstance(metadata.get("input_specs"), list) else []
|
|
1012
|
+
return (
|
|
1013
|
+
f"- {capability_id}: {metadata.get('description') or 'No description provided.'} "
|
|
1014
|
+
f"| service={metadata.get('service_name') or 'unknown'} "
|
|
1015
|
+
f"| inputs={render_compact_agent_input_summary(input_specs)} "
|
|
1016
|
+
f"| side_effect={metadata.get('side_effect') or 'unknown'}{approval} "
|
|
1017
|
+
f"| produces={','.join(produces) or 'none'} "
|
|
1018
|
+
f"| forbids={','.join(forbidden) or 'none'}"
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def build_compact_agent_capability_brief(
|
|
1023
|
+
conversation: str,
|
|
1024
|
+
metadata: dict[str, dict[str, Any]],
|
|
1025
|
+
*,
|
|
1026
|
+
top_n: int = 10,
|
|
1027
|
+
) -> tuple[str, dict[str, Any]]:
|
|
1028
|
+
"""Build a compact top-N routing brief from full runtime metadata.
|
|
1029
|
+
|
|
1030
|
+
The returned brief is an optimization artifact only. Callers must retain the
|
|
1031
|
+
full metadata for normalization, invocation, permission, approval, denial,
|
|
1032
|
+
recovery, and audit behavior.
|
|
1033
|
+
"""
|
|
1034
|
+
bounded_top_n = max(1, int(top_n or 1))
|
|
1035
|
+
scored = sorted(
|
|
1036
|
+
(
|
|
1037
|
+
(
|
|
1038
|
+
compact_capability_match_score(conversation, capability_id, capability_metadata),
|
|
1039
|
+
capability_id,
|
|
1040
|
+
capability_metadata,
|
|
1041
|
+
)
|
|
1042
|
+
for capability_id, capability_metadata in metadata.items()
|
|
1043
|
+
),
|
|
1044
|
+
key=lambda item: (-item[0], item[1]),
|
|
1045
|
+
)
|
|
1046
|
+
selected = scored[: min(bounded_top_n, len(scored))]
|
|
1047
|
+
lines = [
|
|
1048
|
+
"Compact ANIP capability candidates selected by local retrieval.",
|
|
1049
|
+
"The model must choose only from these candidate capability IDs.",
|
|
1050
|
+
]
|
|
1051
|
+
lines.extend(render_compact_agent_capability_line(capability_id, capability_metadata) for _, capability_id, capability_metadata in selected)
|
|
1052
|
+
brief = "\n".join(lines)
|
|
1053
|
+
return brief, {
|
|
1054
|
+
"compact_catalog": True,
|
|
1055
|
+
"compact_top_n": len(selected),
|
|
1056
|
+
"compact_candidate_ids": [capability_id for _, capability_id, _ in selected],
|
|
1057
|
+
"compact_candidate_scores": {capability_id: round(score, 4) for score, capability_id, _ in selected},
|
|
1058
|
+
"compact_brief_chars": len(brief),
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _capability_map(raw_value: Any) -> dict[str, dict[str, Any]]:
|
|
1063
|
+
if isinstance(raw_value, dict):
|
|
1064
|
+
return {str(key): value for key, value in raw_value.items() if isinstance(value, dict)}
|
|
1065
|
+
if isinstance(raw_value, list):
|
|
1066
|
+
return {
|
|
1067
|
+
str(item.get("name")): item
|
|
1068
|
+
for item in raw_value
|
|
1069
|
+
if isinstance(item, dict) and str(item.get("name") or "").strip()
|
|
1070
|
+
}
|
|
1071
|
+
return {}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def build_agent_capability_catalog(
|
|
1075
|
+
service_payloads: list[dict[str, Any]],
|
|
1076
|
+
profile_capability_metadata: dict[str, dict[str, Any]] | None = None,
|
|
1077
|
+
*,
|
|
1078
|
+
allow_duplicate_service_urls: bool = False,
|
|
1079
|
+
) -> dict[str, Any]:
|
|
1080
|
+
"""Build compact prompt briefs and full runtime metadata from ANIP discovery payloads.
|
|
1081
|
+
|
|
1082
|
+
Network discovery remains outside this helper. Callers pass already-fetched
|
|
1083
|
+
`.well-known/anip` and manifest payloads, so the shared runtime utility stays
|
|
1084
|
+
dependency-free and usable across app runtimes.
|
|
1085
|
+
"""
|
|
1086
|
+
profile_capability_metadata = profile_capability_metadata or {}
|
|
1087
|
+
metadata: dict[str, dict[str, Any]] = {}
|
|
1088
|
+
routing_lines: list[str] = []
|
|
1089
|
+
detail_lines: list[str] = []
|
|
1090
|
+
details_by_capability: dict[str, str] = {}
|
|
1091
|
+
services: list[dict[str, Any]] = []
|
|
1092
|
+
seen_urls: dict[str, str] = {}
|
|
1093
|
+
|
|
1094
|
+
for index, service in enumerate(service_payloads):
|
|
1095
|
+
service_name = str(service.get("name") or service.get("service") or f"service-{index + 1}").strip()
|
|
1096
|
+
service_url = str(service.get("url") or "").strip().rstrip("/")
|
|
1097
|
+
if not service_name or not service_url:
|
|
1098
|
+
continue
|
|
1099
|
+
if not allow_duplicate_service_urls and service_url in seen_urls:
|
|
1100
|
+
raise ValueError(
|
|
1101
|
+
"Duplicate ANIP service URL discovered "
|
|
1102
|
+
f"({service_url!r} for {seen_urls[service_url]!r} and {service_name!r}). "
|
|
1103
|
+
"Use distinct service endpoints or explicitly allow duplicate URLs."
|
|
1104
|
+
)
|
|
1105
|
+
seen_urls[service_url] = service_name
|
|
1106
|
+
normalized_service = {"name": service_name, "url": service_url}
|
|
1107
|
+
for field in ("approval_list_path", "approval_approve_path_template"):
|
|
1108
|
+
value = str(service.get(field) or "").strip()
|
|
1109
|
+
if value:
|
|
1110
|
+
normalized_service[field] = value
|
|
1111
|
+
services.append(normalized_service)
|
|
1112
|
+
|
|
1113
|
+
discovery_payload = service.get("discovery")
|
|
1114
|
+
discovery = discovery_payload.get("anip_discovery", discovery_payload) if isinstance(discovery_payload, dict) else {}
|
|
1115
|
+
manifest = service.get("manifest") if isinstance(service.get("manifest"), dict) else {}
|
|
1116
|
+
discovery_caps = _capability_map(discovery.get("capabilities") if isinstance(discovery, dict) else None)
|
|
1117
|
+
manifest_caps = _capability_map(manifest.get("capabilities"))
|
|
1118
|
+
|
|
1119
|
+
for capability_id in sorted(set(discovery_caps) | set(manifest_caps)):
|
|
1120
|
+
discovery_cap = discovery_caps.get(capability_id, {})
|
|
1121
|
+
manifest_cap = manifest_caps.get(capability_id, {})
|
|
1122
|
+
input_specs = [
|
|
1123
|
+
item
|
|
1124
|
+
for item in manifest_cap.get("inputs", [])
|
|
1125
|
+
if isinstance(item, dict) and item.get("name")
|
|
1126
|
+
]
|
|
1127
|
+
input_names = [str(item.get("name")) for item in input_specs]
|
|
1128
|
+
rendered_inputs = [render_agent_input_spec(item) for item in input_specs]
|
|
1129
|
+
side_effect = discovery_cap.get("side_effect") or manifest_cap.get("side_effect", {}).get("type") or "unknown"
|
|
1130
|
+
minimum_scope = discovery_cap.get("minimum_scope") or manifest_cap.get("minimum_scope") or []
|
|
1131
|
+
description = str(discovery_cap.get("description") or manifest_cap.get("description") or "").strip()
|
|
1132
|
+
profile_metadata = profile_capability_metadata.get(capability_id)
|
|
1133
|
+
if not isinstance(profile_metadata, dict):
|
|
1134
|
+
profile_metadata = {}
|
|
1135
|
+
profile_metadata = metadata_with_manifest_controls(profile_metadata, manifest_cap)
|
|
1136
|
+
business_effects = effective_business_effects(discovery_cap, manifest_cap, profile_metadata)
|
|
1137
|
+
app_profile = {
|
|
1138
|
+
key: value
|
|
1139
|
+
for key, value in profile_metadata.items()
|
|
1140
|
+
if key
|
|
1141
|
+
in {
|
|
1142
|
+
"capability_framing",
|
|
1143
|
+
"input_meanings",
|
|
1144
|
+
"input_semantics",
|
|
1145
|
+
"reference_catalogs",
|
|
1146
|
+
"result_display",
|
|
1147
|
+
"app_boundaries",
|
|
1148
|
+
"approval",
|
|
1149
|
+
"required_context",
|
|
1150
|
+
"app_glue",
|
|
1151
|
+
"derived_target_owner",
|
|
1152
|
+
"intent_rules",
|
|
1153
|
+
"business_language_rules",
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
metadata[capability_id] = {
|
|
1157
|
+
"capability_id": capability_id,
|
|
1158
|
+
"description": description,
|
|
1159
|
+
"minimum_scope": minimum_scope,
|
|
1160
|
+
"inputs": input_names,
|
|
1161
|
+
"input_specs": input_specs,
|
|
1162
|
+
"side_effect": side_effect,
|
|
1163
|
+
"grant_policy": manifest_cap.get("grant_policy") if isinstance(manifest_cap.get("grant_policy"), dict) else None,
|
|
1164
|
+
"business_effects": business_effects,
|
|
1165
|
+
"input_semantics": profile_metadata.get("input_semantics") if isinstance(profile_metadata.get("input_semantics"), list) else [],
|
|
1166
|
+
"app_profile": app_profile,
|
|
1167
|
+
"runtime_customization": profile_metadata.get("runtime_customization")
|
|
1168
|
+
if isinstance(profile_metadata.get("runtime_customization"), dict)
|
|
1169
|
+
else None,
|
|
1170
|
+
"service_name": service_name,
|
|
1171
|
+
"service_url": service_url,
|
|
1172
|
+
}
|
|
1173
|
+
base_line = (
|
|
1174
|
+
f"- {capability_id}: {description or 'No description provided.'} "
|
|
1175
|
+
f"| service={service_name} "
|
|
1176
|
+
f"| inputs={', '.join(rendered_inputs) or 'none'} "
|
|
1177
|
+
f"| minimum_scope={', '.join(str(scope) for scope in minimum_scope) or 'none'} "
|
|
1178
|
+
f"| side_effect={side_effect}"
|
|
1179
|
+
f"{render_agent_business_effects(business_effects)}"
|
|
1180
|
+
)
|
|
1181
|
+
routing_line = f"{base_line}{render_agent_routing_metadata(profile_metadata)}"
|
|
1182
|
+
detail_line = f"{base_line}{render_agent_detail_metadata(profile_metadata)}"
|
|
1183
|
+
routing_lines.append(routing_line)
|
|
1184
|
+
detail_lines.append(detail_line)
|
|
1185
|
+
details_by_capability[capability_id] = detail_line
|
|
1186
|
+
|
|
1187
|
+
if not metadata:
|
|
1188
|
+
raise ValueError("No ANIP capabilities were discovered from configured services")
|
|
1189
|
+
|
|
1190
|
+
routing_brief = "\n".join(routing_lines)
|
|
1191
|
+
detail_brief = "\n".join(detail_lines)
|
|
1192
|
+
return {
|
|
1193
|
+
"routing_brief": routing_brief,
|
|
1194
|
+
"detail_brief": detail_brief,
|
|
1195
|
+
"details_by_capability": details_by_capability,
|
|
1196
|
+
"metadata": metadata,
|
|
1197
|
+
"services": services,
|
|
1198
|
+
"stats": {
|
|
1199
|
+
"service_count": len(services),
|
|
1200
|
+
"capability_count": len(metadata),
|
|
1201
|
+
"routing_brief_chars": len(routing_brief),
|
|
1202
|
+
"detail_brief_chars": len(detail_brief),
|
|
1203
|
+
},
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def selected_agent_capability_detail(catalog: dict[str, Any], capability_id: str) -> str:
|
|
1208
|
+
details = catalog.get("details_by_capability")
|
|
1209
|
+
if isinstance(details, dict):
|
|
1210
|
+
value = details.get(capability_id)
|
|
1211
|
+
if isinstance(value, str):
|
|
1212
|
+
return value
|
|
1213
|
+
return ""
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def app_boundaries_for(capability_metadata: dict[str, Any]) -> dict[str, Any]:
|
|
1217
|
+
app_profile = capability_metadata.get("app_profile")
|
|
1218
|
+
profile_boundaries = app_profile.get("app_boundaries") if isinstance(app_profile, dict) else None
|
|
1219
|
+
if isinstance(profile_boundaries, dict):
|
|
1220
|
+
return profile_boundaries
|
|
1221
|
+
direct_boundaries = capability_metadata.get("app_boundaries")
|
|
1222
|
+
return direct_boundaries if isinstance(direct_boundaries, dict) else {}
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def business_language_rules_for(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1226
|
+
rules: list[dict[str, Any]] = []
|
|
1227
|
+
app_profile = capability_metadata.get("app_profile")
|
|
1228
|
+
profile_rules = app_profile.get("business_language_rules") if isinstance(app_profile, dict) else None
|
|
1229
|
+
if isinstance(profile_rules, list):
|
|
1230
|
+
rules.extend(rule for rule in profile_rules if isinstance(rule, dict))
|
|
1231
|
+
direct_rules = capability_metadata.get("business_language_rules")
|
|
1232
|
+
if isinstance(direct_rules, list):
|
|
1233
|
+
rules.extend(rule for rule in direct_rules if isinstance(rule, dict))
|
|
1234
|
+
rules.extend(runtime_business_language_rules_for(capability_metadata))
|
|
1235
|
+
deduped: list[dict[str, Any]] = []
|
|
1236
|
+
seen: set[str] = set()
|
|
1237
|
+
for rule in rules:
|
|
1238
|
+
key = str(rule.get("id") or repr(sorted(rule.items())))
|
|
1239
|
+
if key in seen:
|
|
1240
|
+
continue
|
|
1241
|
+
seen.add(key)
|
|
1242
|
+
deduped.append(rule)
|
|
1243
|
+
return deduped
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _condition_terms(value: Any) -> list[str]:
|
|
1247
|
+
if not isinstance(value, list):
|
|
1248
|
+
return []
|
|
1249
|
+
return [str(item).strip().lower() for item in value if str(item or "").strip()]
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def business_language_rule_matches(conversation: str, rule: dict[str, Any]) -> bool:
|
|
1253
|
+
condition = rule.get("applies_when")
|
|
1254
|
+
if not isinstance(condition, dict):
|
|
1255
|
+
return False
|
|
1256
|
+
lowered = str(conversation or "").lower()
|
|
1257
|
+
all_terms = _condition_terms(condition.get("all_terms"))
|
|
1258
|
+
any_terms = _condition_terms(condition.get("any_terms"))
|
|
1259
|
+
exclude_terms = _condition_terms(condition.get("exclude_terms"))
|
|
1260
|
+
if all_terms and not all(term in lowered for term in all_terms):
|
|
1261
|
+
return False
|
|
1262
|
+
if any_terms and not any(term in lowered for term in any_terms):
|
|
1263
|
+
return False
|
|
1264
|
+
if exclude_terms and any(term in lowered for term in exclude_terms):
|
|
1265
|
+
return False
|
|
1266
|
+
return bool(all_terms or any_terms)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def matching_business_language_rules(
|
|
1270
|
+
conversation: str,
|
|
1271
|
+
capability_metadata: dict[str, Any],
|
|
1272
|
+
) -> list[dict[str, Any]]:
|
|
1273
|
+
return [
|
|
1274
|
+
rule
|
|
1275
|
+
for rule in business_language_rules_for(capability_metadata)
|
|
1276
|
+
if business_language_rule_matches(conversation, rule)
|
|
1277
|
+
]
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def rule_suppressed_effects(rule: dict[str, Any]) -> set[str]:
|
|
1281
|
+
values = rule.get("suppress_unsupported_effects")
|
|
1282
|
+
if isinstance(values, list):
|
|
1283
|
+
return {str(value) for value in values if str(value or "").strip()}
|
|
1284
|
+
if str(rule.get("agent_action") or "") in {"treat_as_supported", "treat_as_purpose"}:
|
|
1285
|
+
return {"*"}
|
|
1286
|
+
return set()
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def supported_by_business_language_rule(
|
|
1290
|
+
conversation: str,
|
|
1291
|
+
capability_metadata: dict[str, Any],
|
|
1292
|
+
unsupported_effects: set[str] | None = None,
|
|
1293
|
+
) -> bool:
|
|
1294
|
+
effects = unsupported_effects or set()
|
|
1295
|
+
for rule in matching_business_language_rules(conversation, capability_metadata):
|
|
1296
|
+
suppressed = rule_suppressed_effects(rule)
|
|
1297
|
+
if "*" in suppressed:
|
|
1298
|
+
return True
|
|
1299
|
+
if effects and effects <= suppressed:
|
|
1300
|
+
return True
|
|
1301
|
+
return False
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def conditional_approval_boundary(capability_metadata: dict[str, Any]) -> dict[str, Any]:
|
|
1305
|
+
boundary = app_boundaries_for(capability_metadata).get("conditional_approval_boundary")
|
|
1306
|
+
return boundary if isinstance(boundary, dict) else {}
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def conditional_approval_missing_inputs(capability_metadata: dict[str, Any]) -> set[str]:
|
|
1310
|
+
when_missing = conditional_approval_boundary(capability_metadata).get("when_missing")
|
|
1311
|
+
return {str(item) for item in when_missing if str(item or "").strip()} if isinstance(when_missing, list) else set()
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def conditional_approval_produces(capability_metadata: dict[str, Any]) -> set[str]:
|
|
1315
|
+
produces = conditional_approval_boundary(capability_metadata).get("produces")
|
|
1316
|
+
return {str(item) for item in produces if str(item or "").strip()} if isinstance(produces, list) else set()
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def has_conditional_approval_boundary(capability_metadata: dict[str, Any]) -> bool:
|
|
1320
|
+
return bool(
|
|
1321
|
+
conditional_approval_missing_inputs(capability_metadata)
|
|
1322
|
+
and ({"approval.request", "system.preview_mutation"} & conditional_approval_produces(capability_metadata))
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def is_conditional_approval_boundary_active(
|
|
1327
|
+
capability_metadata: dict[str, Any],
|
|
1328
|
+
parameter_values: dict[str, Any] | None,
|
|
1329
|
+
) -> bool:
|
|
1330
|
+
missing_inputs = conditional_approval_missing_inputs(capability_metadata)
|
|
1331
|
+
if not missing_inputs:
|
|
1332
|
+
return False
|
|
1333
|
+
values = parameter_values if isinstance(parameter_values, dict) else {}
|
|
1334
|
+
for input_name in missing_inputs:
|
|
1335
|
+
value = values.get(input_name)
|
|
1336
|
+
if value is None or value == "" or value == []:
|
|
1337
|
+
return True
|
|
1338
|
+
return False
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def requested_unsupported_effects(conversation: str, capability_metadata: dict[str, Any]) -> set[str]:
|
|
1342
|
+
tokens = text_tokens(conversation)
|
|
1343
|
+
ordered_tokens = ordered_text_tokens(conversation)
|
|
1344
|
+
blocked = capability_does_not_produce(capability_metadata)
|
|
1345
|
+
produced = capability_produces(capability_metadata)
|
|
1346
|
+
requested: set[str] = set()
|
|
1347
|
+
if "approval.execute" in blocked and requests_approval_bypass(conversation):
|
|
1348
|
+
requested.add("approval.execute")
|
|
1349
|
+
unsupported_terms = app_boundaries_for(capability_metadata).get("unsupported_terms")
|
|
1350
|
+
if isinstance(unsupported_terms, dict):
|
|
1351
|
+
lowered = str(conversation or "").lower()
|
|
1352
|
+
for effect, terms in unsupported_terms.items():
|
|
1353
|
+
if not isinstance(terms, list):
|
|
1354
|
+
continue
|
|
1355
|
+
for term in terms:
|
|
1356
|
+
term_text = str(term or "").strip().lower()
|
|
1357
|
+
if term_text and term_text in lowered:
|
|
1358
|
+
requested.add(str(effect))
|
|
1359
|
+
matching_rules = matching_business_language_rules(conversation, capability_metadata)
|
|
1360
|
+
for effect, terms in UNSUPPORTED_EFFECT_TERMS.items():
|
|
1361
|
+
matched_terms = tokens & terms
|
|
1362
|
+
if not matched_terms:
|
|
1363
|
+
continue
|
|
1364
|
+
if all(_term_is_negated(ordered_tokens, term) for term in matched_terms):
|
|
1365
|
+
continue
|
|
1366
|
+
if (
|
|
1367
|
+
effect in blocked
|
|
1368
|
+
or (effect == "raw_data_export" and "raw_data_export" not in produced)
|
|
1369
|
+
or (effect == "external_dispatch" and "content.draft" in produced)
|
|
1370
|
+
):
|
|
1371
|
+
requested.add(effect)
|
|
1372
|
+
for rule in matching_rules:
|
|
1373
|
+
suppressed = rule_suppressed_effects(rule)
|
|
1374
|
+
if "*" in suppressed:
|
|
1375
|
+
requested.clear()
|
|
1376
|
+
break
|
|
1377
|
+
requested -= suppressed
|
|
1378
|
+
return requested
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def requests_approval_bypass(conversation: str) -> bool:
|
|
1382
|
+
lowered = str(conversation or "").lower()
|
|
1383
|
+
return bool(
|
|
1384
|
+
re.search(r"\b(?:without|no|skip|bypass|ignore|omit)\s+(?:any\s+)?approval\b", lowered)
|
|
1385
|
+
or re.search(r"\bapproval\s+(?:not\s+)?(?:needed|required|necessary)\b", lowered)
|
|
1386
|
+
or re.search(r"\bdon'?t\s+(?:ask for|request|require)\s+approval\b", lowered)
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def requested_primary_content_effect(conversation: str) -> str | None:
|
|
1391
|
+
tokens = text_tokens(conversation)
|
|
1392
|
+
ordered_tokens = ordered_text_tokens(conversation)
|
|
1393
|
+
if any(
|
|
1394
|
+
token in tokens and not _term_is_negated(ordered_tokens, token)
|
|
1395
|
+
for token in {"recommend", "recommendation", "recommendations", "variant", "variants", "option", "options"}
|
|
1396
|
+
):
|
|
1397
|
+
return "content.recommendation"
|
|
1398
|
+
if any(
|
|
1399
|
+
token in tokens and not _term_is_negated(ordered_tokens, token)
|
|
1400
|
+
for token in {"draft", "email", "outreach", "message"}
|
|
1401
|
+
):
|
|
1402
|
+
return "content.draft"
|
|
1403
|
+
if any(token in tokens and not _term_is_negated(ordered_tokens, token) for token in {"summarize", "summary"}):
|
|
1404
|
+
return "content.summary"
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def should_clear_planner_unsupported_for_approval_boundary(
|
|
1409
|
+
conversation: str,
|
|
1410
|
+
capability_metadata: dict[str, Any],
|
|
1411
|
+
*,
|
|
1412
|
+
parameter_values: dict[str, Any] | None = None,
|
|
1413
|
+
requested_effects: set[str] | None = None,
|
|
1414
|
+
) -> bool:
|
|
1415
|
+
"""Allow approval-boundary capabilities to own safe compound requests.
|
|
1416
|
+
|
|
1417
|
+
Planner models sometimes mark a compound request unsupported because a
|
|
1418
|
+
secondary sub-intent is outside the selected approval capability. If the
|
|
1419
|
+
selected capability is itself the governed approval boundary and no
|
|
1420
|
+
explicitly blocked effect was detected from metadata, the service should
|
|
1421
|
+
respond with its declared approval/preview outcome instead of the agent
|
|
1422
|
+
denying the request pre-invocation.
|
|
1423
|
+
"""
|
|
1424
|
+
|
|
1425
|
+
conditional_active = is_conditional_approval_boundary_active(capability_metadata, parameter_values)
|
|
1426
|
+
if not conditional_active and (not is_approval_capability(capability_metadata) or not has_approval_intent(conversation)):
|
|
1427
|
+
return False
|
|
1428
|
+
return not (requested_effects or requested_unsupported_effects(conversation, capability_metadata))
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def should_clear_planner_unsupported_for_declared_effect(
|
|
1432
|
+
conversation: str,
|
|
1433
|
+
capability_metadata: dict[str, Any],
|
|
1434
|
+
*,
|
|
1435
|
+
requested_effects: set[str] | None = None,
|
|
1436
|
+
) -> bool:
|
|
1437
|
+
"""Prefer deterministic contract effects over a model-only unsupported flag."""
|
|
1438
|
+
|
|
1439
|
+
if requested_effects or requested_unsupported_effects(conversation, capability_metadata):
|
|
1440
|
+
return False
|
|
1441
|
+
requested_effect = requested_primary_content_effect(conversation)
|
|
1442
|
+
if not requested_effect:
|
|
1443
|
+
return False
|
|
1444
|
+
return requested_effect in capability_produces(capability_metadata)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _term_is_negated(tokens: list[str], term: str) -> bool:
|
|
1448
|
+
for index, token in enumerate(tokens):
|
|
1449
|
+
if token != term:
|
|
1450
|
+
continue
|
|
1451
|
+
window = tokens[max(0, index - 6):index]
|
|
1452
|
+
if "without" in window or "not" in window or "no" in window or "exclude" in window or "avoid" in window:
|
|
1453
|
+
return True
|
|
1454
|
+
if len(window) >= 2 and window[-2:] == ["do", "not"]:
|
|
1455
|
+
return True
|
|
1456
|
+
return False
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def has_approval_intent(conversation: str) -> bool:
|
|
1460
|
+
tokens = text_tokens(conversation)
|
|
1461
|
+
matched_terms = tokens & APPROVAL_INTENT_TERMS
|
|
1462
|
+
if not matched_terms:
|
|
1463
|
+
return False
|
|
1464
|
+
ordered_tokens = ordered_text_tokens(conversation)
|
|
1465
|
+
return any(not _term_is_negated(ordered_tokens, term) for term in matched_terms)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def is_approval_capability(capability_metadata: dict[str, Any]) -> bool:
|
|
1469
|
+
produced = capability_produces(capability_metadata)
|
|
1470
|
+
if {"approval.request", "system.preview_mutation"} & produced:
|
|
1471
|
+
return True
|
|
1472
|
+
approval = capability_metadata.get("app_profile", {}).get("approval") or capability_metadata.get("approval")
|
|
1473
|
+
return isinstance(approval, dict) and approval.get("required") is True
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def capability_match_score(conversation: str, capability_id: str, capability_metadata: dict[str, Any]) -> float:
|
|
1477
|
+
input_fragments: list[str] = []
|
|
1478
|
+
for item in capability_metadata.get("input_specs") or []:
|
|
1479
|
+
if not isinstance(item, dict):
|
|
1480
|
+
continue
|
|
1481
|
+
input_fragments.append(str(item.get("name") or ""))
|
|
1482
|
+
input_fragments.extend(str(value) for value in item.get("allowed_values") or [])
|
|
1483
|
+
input_names = " ".join(input_fragments)
|
|
1484
|
+
intent = capability_metadata.get("app_profile", {}).get("intent")
|
|
1485
|
+
intent_text = ""
|
|
1486
|
+
if isinstance(intent, dict):
|
|
1487
|
+
intent_text = f"{intent.get('category', '')} {intent.get('summary', '')}"
|
|
1488
|
+
profile_text = ""
|
|
1489
|
+
app_profile = capability_metadata.get("app_profile")
|
|
1490
|
+
if isinstance(app_profile, dict):
|
|
1491
|
+
profile_text = " ".join(
|
|
1492
|
+
str(value)
|
|
1493
|
+
for key in ("capability_framing", "input_meanings", "reference_catalogs", "app_boundaries")
|
|
1494
|
+
for value in [app_profile.get(key)]
|
|
1495
|
+
if value is not None
|
|
1496
|
+
)
|
|
1497
|
+
framing_text = " ".join(
|
|
1498
|
+
str(capability_metadata.get(key) or "")
|
|
1499
|
+
for key in ("capability_framing", "summary", "output_intent")
|
|
1500
|
+
)
|
|
1501
|
+
haystack = f"{capability_id} {capability_metadata.get('description', '')} {framing_text} {input_names} {intent_text} {profile_text}"
|
|
1502
|
+
customization = runtime_customization_for(capability_metadata)
|
|
1503
|
+
source_tokens = capability_score_token_variants(conversation, customization)
|
|
1504
|
+
target_tokens = capability_score_token_variants(haystack, customization)
|
|
1505
|
+
if not source_tokens or not target_tokens:
|
|
1506
|
+
return 0.0
|
|
1507
|
+
overlap = source_tokens & target_tokens
|
|
1508
|
+
recall = len(overlap) / max(1, len(source_tokens))
|
|
1509
|
+
precision = len(overlap) / max(1, len(target_tokens))
|
|
1510
|
+
id_tokens = capability_score_token_variants(capability_id, customization)
|
|
1511
|
+
id_precision = len(source_tokens & id_tokens) / max(1, len(id_tokens))
|
|
1512
|
+
return (recall * 0.65) + (precision * 0.25) + (id_precision * 0.10)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
READ_INTENT_TOKENS = {
|
|
1516
|
+
"biggest",
|
|
1517
|
+
"breakdown",
|
|
1518
|
+
"explain",
|
|
1519
|
+
"forecast",
|
|
1520
|
+
"health",
|
|
1521
|
+
"list",
|
|
1522
|
+
"rank",
|
|
1523
|
+
"ranking",
|
|
1524
|
+
"review",
|
|
1525
|
+
"show",
|
|
1526
|
+
"summarize",
|
|
1527
|
+
"summary",
|
|
1528
|
+
"top",
|
|
1529
|
+
"why",
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _conversation_has_read_intent(conversation: str) -> bool:
|
|
1534
|
+
tokens = text_tokens(conversation)
|
|
1535
|
+
return bool(tokens & READ_INTENT_TOKENS)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def compact_capability_match_score(conversation: str, capability_id: str, capability_metadata: dict[str, Any]) -> float:
|
|
1539
|
+
"""Score a capability for compact retrieval.
|
|
1540
|
+
|
|
1541
|
+
Compact retrieval has a stronger obligation than ordinary ranking: if it
|
|
1542
|
+
omits the right capability, the planner cannot recover. This score keeps
|
|
1543
|
+
the base semantic overlap but adds small contract-posture adjustments so
|
|
1544
|
+
read-only requests prefer read/summary capabilities over approval or
|
|
1545
|
+
mutation-preparation capabilities unless the conversation itself contains
|
|
1546
|
+
approval/write-adjacent intent.
|
|
1547
|
+
"""
|
|
1548
|
+
|
|
1549
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1550
|
+
produced = capability_produces(capability_metadata)
|
|
1551
|
+
read_intent = _conversation_has_read_intent(conversation)
|
|
1552
|
+
approval_intent = has_approval_intent(conversation)
|
|
1553
|
+
unsupported_effects = requested_unsupported_effects(conversation, capability_metadata)
|
|
1554
|
+
if read_intent and "content.summary" in produced and not is_approval_capability(capability_metadata):
|
|
1555
|
+
score += 0.12
|
|
1556
|
+
if read_intent and is_approval_capability(capability_metadata) and not approval_intent:
|
|
1557
|
+
score -= 0.08
|
|
1558
|
+
if unsupported_effects:
|
|
1559
|
+
score += 0.03
|
|
1560
|
+
return max(0.0, score)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def missing_required_input_names(
|
|
1564
|
+
conversation: str,
|
|
1565
|
+
capability_metadata: dict[str, Any],
|
|
1566
|
+
parameter_values: dict[str, Any] | None = None,
|
|
1567
|
+
) -> set[str]:
|
|
1568
|
+
seed_parameters = parameter_values if isinstance(parameter_values, dict) else {}
|
|
1569
|
+
inferred_parameters = normalize_declared_parameters(seed_parameters, conversation, capability_metadata)
|
|
1570
|
+
missing: set[str] = set()
|
|
1571
|
+
for input_spec in capability_metadata.get("input_specs", []):
|
|
1572
|
+
if not isinstance(input_spec, dict) or input_spec.get("required") is not True:
|
|
1573
|
+
continue
|
|
1574
|
+
name = str(input_spec.get("name") or "")
|
|
1575
|
+
if not name or name in inferred_parameters:
|
|
1576
|
+
continue
|
|
1577
|
+
resolution = input_spec.get("resolution") if isinstance(input_spec.get("resolution"), dict) else {}
|
|
1578
|
+
default_value = input_spec.get("default")
|
|
1579
|
+
if (
|
|
1580
|
+
default_value is not None
|
|
1581
|
+
and default_value != ""
|
|
1582
|
+
and default_value != []
|
|
1583
|
+
and resolution.get("on_missing") == "use_default"
|
|
1584
|
+
):
|
|
1585
|
+
continue
|
|
1586
|
+
missing.add(name)
|
|
1587
|
+
return missing
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _missing_required_inputs_are_referenced(
|
|
1591
|
+
conversation: str,
|
|
1592
|
+
capability_metadata: dict[str, Any],
|
|
1593
|
+
missing_inputs: set[str],
|
|
1594
|
+
) -> bool:
|
|
1595
|
+
if not missing_inputs:
|
|
1596
|
+
return False
|
|
1597
|
+
conversation_tokens = text_tokens(conversation)
|
|
1598
|
+
if not conversation_tokens:
|
|
1599
|
+
return False
|
|
1600
|
+
weak_tokens = {"id", "ids", "input", "name", "names", "ref", "reference", "value", "values"}
|
|
1601
|
+
input_specs = {
|
|
1602
|
+
str(item.get("name") or ""): item
|
|
1603
|
+
for item in capability_metadata.get("input_specs", [])
|
|
1604
|
+
if isinstance(item, dict)
|
|
1605
|
+
}
|
|
1606
|
+
for input_name in missing_inputs:
|
|
1607
|
+
input_spec = input_specs.get(input_name, {})
|
|
1608
|
+
input_text = " ".join(
|
|
1609
|
+
str(value or "")
|
|
1610
|
+
for value in (
|
|
1611
|
+
input_name,
|
|
1612
|
+
input_spec.get("semantic_type"),
|
|
1613
|
+
input_spec.get("description"),
|
|
1614
|
+
)
|
|
1615
|
+
)
|
|
1616
|
+
input_tokens = text_tokens(input_text) - weak_tokens
|
|
1617
|
+
if not input_tokens or not (conversation_tokens & input_tokens):
|
|
1618
|
+
return False
|
|
1619
|
+
return True
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _same_effect_class(first: dict[str, Any], second: dict[str, Any]) -> bool:
|
|
1623
|
+
first_produces = capability_produces(first)
|
|
1624
|
+
second_produces = capability_produces(second)
|
|
1625
|
+
if is_approval_capability(first) and is_approval_capability(second):
|
|
1626
|
+
return True
|
|
1627
|
+
return bool(first_produces & second_produces)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def select_grounded_capability(
|
|
1631
|
+
conversation: str,
|
|
1632
|
+
selected_capability: str,
|
|
1633
|
+
metadata: dict[str, dict[str, Any]],
|
|
1634
|
+
parameter_values: dict[str, Any] | None = None,
|
|
1635
|
+
) -> str:
|
|
1636
|
+
"""Prefer a grounded peer when the model picked an ungrounded capability.
|
|
1637
|
+
|
|
1638
|
+
This is intentionally generic: if the selected capability cannot bind its
|
|
1639
|
+
required reviewed inputs from the user-authored conversation, do not keep it
|
|
1640
|
+
solely because the model chose it. A peer with the same effect class and
|
|
1641
|
+
grounded required inputs is safer because it can produce an executable ANIP
|
|
1642
|
+
invocation instead of falling through to avoidable clarification.
|
|
1643
|
+
"""
|
|
1644
|
+
|
|
1645
|
+
selected_metadata = metadata[selected_capability]
|
|
1646
|
+
selected_missing = missing_required_input_names(conversation, selected_metadata, parameter_values)
|
|
1647
|
+
if not selected_missing:
|
|
1648
|
+
return selected_capability
|
|
1649
|
+
|
|
1650
|
+
selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
|
|
1651
|
+
customization = runtime_customization_for(selected_metadata)
|
|
1652
|
+
min_score = _configured_float(customization, "grounded_peer_min_score", 0.12)
|
|
1653
|
+
margin = _configured_float(customization, "grounded_peer_margin", 0.02)
|
|
1654
|
+
different_missing_margin = _configured_float(customization, "grounded_peer_different_missing_margin", 0.25)
|
|
1655
|
+
best_capability = selected_capability
|
|
1656
|
+
best_score = 0.0
|
|
1657
|
+
best_missing_count = len(selected_missing)
|
|
1658
|
+
|
|
1659
|
+
for capability_id, capability_metadata in metadata.items():
|
|
1660
|
+
if capability_id == selected_capability:
|
|
1661
|
+
continue
|
|
1662
|
+
if not _same_effect_class(selected_metadata, capability_metadata):
|
|
1663
|
+
continue
|
|
1664
|
+
missing = missing_required_input_names(conversation, capability_metadata, parameter_values)
|
|
1665
|
+
if len(missing) > best_missing_count:
|
|
1666
|
+
continue
|
|
1667
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1668
|
+
if len(missing) == best_missing_count:
|
|
1669
|
+
if missing != selected_missing:
|
|
1670
|
+
continue
|
|
1671
|
+
if score < selected_score + margin:
|
|
1672
|
+
continue
|
|
1673
|
+
if score > best_score or (best_capability == selected_capability and score == best_score):
|
|
1674
|
+
best_capability = capability_id
|
|
1675
|
+
best_score = score
|
|
1676
|
+
best_missing_count = len(missing)
|
|
1677
|
+
|
|
1678
|
+
if best_capability != selected_capability and (
|
|
1679
|
+
best_score >= max(min_score, selected_score + margin)
|
|
1680
|
+
or (best_missing_count < len(selected_missing) and best_score >= min_score)
|
|
1681
|
+
):
|
|
1682
|
+
return best_capability
|
|
1683
|
+
return selected_capability
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def select_stronger_contract_match_capability(
|
|
1687
|
+
conversation: str,
|
|
1688
|
+
selected_capability: str,
|
|
1689
|
+
metadata: dict[str, dict[str, Any]],
|
|
1690
|
+
) -> str:
|
|
1691
|
+
"""Prefer a clearly better same-effect contract match.
|
|
1692
|
+
|
|
1693
|
+
This is deliberately narrower than free re-ranking. It only considers
|
|
1694
|
+
capabilities that share the selected capability's declared effect class, and
|
|
1695
|
+
it requires a material score margin. The safer outcome can be clarification:
|
|
1696
|
+
if the better-matching contract has missing required context, selecting it
|
|
1697
|
+
lets the service/runtime ask for that context instead of executing a nearby
|
|
1698
|
+
but semantically different capability.
|
|
1699
|
+
"""
|
|
1700
|
+
|
|
1701
|
+
selected_metadata = metadata[selected_capability]
|
|
1702
|
+
selected_missing = missing_required_input_names(conversation, selected_metadata)
|
|
1703
|
+
selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
|
|
1704
|
+
customization = runtime_customization_for(selected_metadata)
|
|
1705
|
+
min_score = _configured_float(customization, "stronger_contract_match_min_score", 0.12)
|
|
1706
|
+
margin = _configured_float(customization, "stronger_contract_match_margin", 0.08)
|
|
1707
|
+
best_capability = selected_capability
|
|
1708
|
+
best_score = selected_score
|
|
1709
|
+
|
|
1710
|
+
for capability_id, capability_metadata in metadata.items():
|
|
1711
|
+
if capability_id == selected_capability:
|
|
1712
|
+
continue
|
|
1713
|
+
if not _same_effect_class(selected_metadata, capability_metadata):
|
|
1714
|
+
continue
|
|
1715
|
+
missing = missing_required_input_names(conversation, capability_metadata)
|
|
1716
|
+
if selected_missing and not _missing_required_inputs_are_referenced(conversation, capability_metadata, missing):
|
|
1717
|
+
continue
|
|
1718
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1719
|
+
if score > best_score:
|
|
1720
|
+
best_capability = capability_id
|
|
1721
|
+
best_score = score
|
|
1722
|
+
|
|
1723
|
+
if best_capability != selected_capability and best_score >= max(min_score, selected_score + margin):
|
|
1724
|
+
return best_capability
|
|
1725
|
+
return selected_capability
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def select_approval_boundary_capability(
|
|
1729
|
+
conversation: str,
|
|
1730
|
+
selected_capability: str,
|
|
1731
|
+
metadata: dict[str, dict[str, Any]],
|
|
1732
|
+
) -> str:
|
|
1733
|
+
selected_metadata = metadata[selected_capability]
|
|
1734
|
+
if (
|
|
1735
|
+
is_approval_capability(selected_metadata)
|
|
1736
|
+
or "content.draft" in capability_produces(selected_metadata)
|
|
1737
|
+
or not has_approval_intent(conversation)
|
|
1738
|
+
):
|
|
1739
|
+
return selected_capability
|
|
1740
|
+
|
|
1741
|
+
best_capability = selected_capability
|
|
1742
|
+
best_score = 0.0
|
|
1743
|
+
customization = runtime_customization_for(selected_metadata)
|
|
1744
|
+
min_score = _configured_float(customization, "approval_boundary_min_score", 0.12)
|
|
1745
|
+
for capability_id, capability_metadata in metadata.items():
|
|
1746
|
+
if not is_approval_capability(capability_metadata):
|
|
1747
|
+
continue
|
|
1748
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1749
|
+
if score > best_score:
|
|
1750
|
+
best_capability = capability_id
|
|
1751
|
+
best_score = score
|
|
1752
|
+
|
|
1753
|
+
return best_capability if best_score >= min_score else selected_capability
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def select_declared_effect_capability(
|
|
1757
|
+
conversation: str,
|
|
1758
|
+
selected_capability: str,
|
|
1759
|
+
metadata: dict[str, dict[str, Any]],
|
|
1760
|
+
) -> str:
|
|
1761
|
+
requested_effect = requested_primary_content_effect(conversation)
|
|
1762
|
+
if (
|
|
1763
|
+
not requested_effect
|
|
1764
|
+
or requested_effect in capability_produces(metadata[selected_capability])
|
|
1765
|
+
or is_approval_capability(metadata[selected_capability])
|
|
1766
|
+
or has_conditional_approval_boundary(metadata[selected_capability])
|
|
1767
|
+
):
|
|
1768
|
+
return selected_capability
|
|
1769
|
+
|
|
1770
|
+
best_capability = selected_capability
|
|
1771
|
+
best_score = 0.0
|
|
1772
|
+
selected_metadata = metadata[selected_capability]
|
|
1773
|
+
selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
|
|
1774
|
+
customization = runtime_customization_for(selected_metadata)
|
|
1775
|
+
min_score = _configured_float(customization, "effect_rewrite_min_score", 0.12)
|
|
1776
|
+
margin = _configured_float(customization, "effect_rewrite_margin", 0.1)
|
|
1777
|
+
for capability_id, capability_metadata in metadata.items():
|
|
1778
|
+
if requested_effect not in capability_produces(capability_metadata):
|
|
1779
|
+
continue
|
|
1780
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1781
|
+
if score > best_score:
|
|
1782
|
+
best_capability = capability_id
|
|
1783
|
+
best_score = score
|
|
1784
|
+
|
|
1785
|
+
if best_capability != selected_capability and best_score >= max(min_score, selected_score + margin):
|
|
1786
|
+
return best_capability
|
|
1787
|
+
return selected_capability
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def select_requested_effect_floor_capability(
|
|
1791
|
+
conversation: str,
|
|
1792
|
+
selected_capability: str,
|
|
1793
|
+
metadata: dict[str, dict[str, Any]],
|
|
1794
|
+
) -> str:
|
|
1795
|
+
"""Avoid keeping a higher-effect capability when the user did not ask for it."""
|
|
1796
|
+
|
|
1797
|
+
selected_metadata = metadata[selected_capability]
|
|
1798
|
+
selected_produces = capability_produces(selected_metadata)
|
|
1799
|
+
requested_effect = requested_primary_content_effect(conversation)
|
|
1800
|
+
selected_needs_explicit_effect = (
|
|
1801
|
+
("content.draft" in selected_produces and requested_effect != "content.draft")
|
|
1802
|
+
or (is_approval_capability(selected_metadata) and not has_approval_intent(conversation))
|
|
1803
|
+
)
|
|
1804
|
+
if is_approval_capability(selected_metadata) and requests_approval_bypass(conversation):
|
|
1805
|
+
return selected_capability
|
|
1806
|
+
if not selected_needs_explicit_effect:
|
|
1807
|
+
return selected_capability
|
|
1808
|
+
|
|
1809
|
+
selected_score = capability_match_score(conversation, selected_capability, selected_metadata)
|
|
1810
|
+
customization = runtime_customization_for(selected_metadata)
|
|
1811
|
+
min_score = _configured_float(customization, "effect_floor_min_score", 0.12)
|
|
1812
|
+
margin = _configured_float(customization, "effect_floor_margin", 0.02)
|
|
1813
|
+
preferred_effects = {requested_effect} if requested_effect else {"content.summary", "data.aggregate", "data.read"}
|
|
1814
|
+
best_capability = selected_capability
|
|
1815
|
+
best_score = 0.0
|
|
1816
|
+
for capability_id, capability_metadata in metadata.items():
|
|
1817
|
+
if capability_id == selected_capability:
|
|
1818
|
+
continue
|
|
1819
|
+
produces = capability_produces(capability_metadata)
|
|
1820
|
+
if "content.draft" in produces or is_approval_capability(capability_metadata) or has_conditional_approval_boundary(capability_metadata):
|
|
1821
|
+
continue
|
|
1822
|
+
if preferred_effects and not (produces & preferred_effects):
|
|
1823
|
+
continue
|
|
1824
|
+
score = capability_match_score(conversation, capability_id, capability_metadata)
|
|
1825
|
+
if score > best_score:
|
|
1826
|
+
best_capability = capability_id
|
|
1827
|
+
best_score = score
|
|
1828
|
+
|
|
1829
|
+
selected_lacks_requested_effect = bool(requested_effect and requested_effect not in selected_produces)
|
|
1830
|
+
required_score = min_score if selected_lacks_requested_effect else max(min_score, selected_score + margin)
|
|
1831
|
+
if best_capability != selected_capability and best_score >= required_score:
|
|
1832
|
+
return best_capability
|
|
1833
|
+
return selected_capability
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def matching_profile_hint(
|
|
1837
|
+
conversation: str,
|
|
1838
|
+
metadata: dict[str, dict[str, Any]],
|
|
1839
|
+
selection_hints: list[dict[str, Any]] | None = None,
|
|
1840
|
+
) -> dict[str, Any] | None:
|
|
1841
|
+
lowered = conversation.lower()
|
|
1842
|
+
for hint in selection_hints or []:
|
|
1843
|
+
if not isinstance(hint, dict):
|
|
1844
|
+
continue
|
|
1845
|
+
capability_id = str(hint.get("capability") or "")
|
|
1846
|
+
if capability_id not in metadata:
|
|
1847
|
+
continue
|
|
1848
|
+
all_terms = [str(term).lower() for term in hint.get("all_terms") or []]
|
|
1849
|
+
any_terms = [str(term).lower() for term in hint.get("any_terms") or []]
|
|
1850
|
+
exclude_terms = [str(term).lower() for term in hint.get("exclude_terms") or []]
|
|
1851
|
+
if all_terms and not all(term in lowered for term in all_terms):
|
|
1852
|
+
continue
|
|
1853
|
+
if any_terms and not any(term in lowered for term in any_terms):
|
|
1854
|
+
continue
|
|
1855
|
+
if exclude_terms and any(term in lowered for term in exclude_terms):
|
|
1856
|
+
continue
|
|
1857
|
+
return hint
|
|
1858
|
+
return None
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def select_profile_hint_capability(
|
|
1862
|
+
conversation: str,
|
|
1863
|
+
selected_capability: str,
|
|
1864
|
+
metadata: dict[str, dict[str, Any]],
|
|
1865
|
+
selection_hints: list[dict[str, Any]] | None = None,
|
|
1866
|
+
) -> str:
|
|
1867
|
+
hint = matching_profile_hint(conversation, metadata, selection_hints)
|
|
1868
|
+
if hint is not None:
|
|
1869
|
+
return str(hint.get("capability") or selected_capability)
|
|
1870
|
+
return selected_capability
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def select_consumable_capability(
|
|
1874
|
+
conversation: str,
|
|
1875
|
+
selected_capability: str,
|
|
1876
|
+
metadata: dict[str, dict[str, Any]],
|
|
1877
|
+
selection_hints: list[dict[str, Any]] | None = None,
|
|
1878
|
+
parameter_values: dict[str, Any] | None = None,
|
|
1879
|
+
) -> str:
|
|
1880
|
+
matched_hint = matching_profile_hint(conversation, metadata, selection_hints)
|
|
1881
|
+
if matched_hint is not None and matched_hint.get("lock_capability") is True:
|
|
1882
|
+
return str(matched_hint.get("capability") or selected_capability)
|
|
1883
|
+
capability = select_profile_hint_capability(conversation, selected_capability, metadata, selection_hints)
|
|
1884
|
+
capability = select_grounded_capability(conversation, capability, metadata, parameter_values)
|
|
1885
|
+
capability = select_requested_effect_floor_capability(conversation, capability, metadata)
|
|
1886
|
+
capability = select_approval_boundary_capability(conversation, capability, metadata)
|
|
1887
|
+
capability = select_stronger_contract_match_capability(conversation, capability, metadata)
|
|
1888
|
+
return select_declared_effect_capability(conversation, capability, metadata)
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
def normalize_declared_parameters(
|
|
1892
|
+
parameters: dict[str, Any],
|
|
1893
|
+
conversation: str,
|
|
1894
|
+
capability_metadata: dict[str, Any],
|
|
1895
|
+
) -> dict[str, Any]:
|
|
1896
|
+
input_specs = [
|
|
1897
|
+
item
|
|
1898
|
+
for item in capability_metadata.get("input_specs") or []
|
|
1899
|
+
if isinstance(item, dict) and item.get("name")
|
|
1900
|
+
]
|
|
1901
|
+
declared_inputs = {str(item.get("name")) for item in input_specs}
|
|
1902
|
+
allowed_values_by_input = {
|
|
1903
|
+
str(item.get("name")): list(all_candidate_values_for_input(capability_metadata, item).keys())
|
|
1904
|
+
for item in input_specs
|
|
1905
|
+
}
|
|
1906
|
+
input_spec_by_name = {str(item.get("name")): item for item in input_specs}
|
|
1907
|
+
filtered_parameters: dict[str, Any] = {}
|
|
1908
|
+
for key, value in parameters.items():
|
|
1909
|
+
if key not in declared_inputs or value is None or value == "":
|
|
1910
|
+
continue
|
|
1911
|
+
input_spec = input_spec_by_name.get(str(key), {})
|
|
1912
|
+
if not is_supported_quarter_input_value(input_spec, value, conversation):
|
|
1913
|
+
continue
|
|
1914
|
+
value = normalize_declared_input_value(input_spec, value, conversation)
|
|
1915
|
+
value = normalize_reference_value(input_spec, value, conversation, capability_metadata)
|
|
1916
|
+
if is_ungrounded_declared_context(input_spec, value, conversation, capability_metadata):
|
|
1917
|
+
continue
|
|
1918
|
+
allowed_values = allowed_values_by_input.get(str(key)) or []
|
|
1919
|
+
if isinstance(value, str) and allowed_values:
|
|
1920
|
+
normalized = next((allowed for allowed in allowed_values if semantic_text_key(allowed) == semantic_text_key(value)), None)
|
|
1921
|
+
if normalized is None:
|
|
1922
|
+
customization = runtime_customization_for(capability_metadata)
|
|
1923
|
+
normalized = canonical_from_candidates(
|
|
1924
|
+
value,
|
|
1925
|
+
conversation,
|
|
1926
|
+
{allowed: allowed.replace("_", " ") for allowed in allowed_values},
|
|
1927
|
+
customization,
|
|
1928
|
+
)
|
|
1929
|
+
if normalized is None:
|
|
1930
|
+
continue
|
|
1931
|
+
value = normalized
|
|
1932
|
+
filtered_parameters[key] = value
|
|
1933
|
+
for input_spec in input_specs:
|
|
1934
|
+
name = str(input_spec.get("name") or "")
|
|
1935
|
+
if not name or name in filtered_parameters:
|
|
1936
|
+
continue
|
|
1937
|
+
inferred = infer_declared_input_value(input_spec, conversation, capability_metadata)
|
|
1938
|
+
if inferred is not None:
|
|
1939
|
+
filtered_parameters[name] = inferred
|
|
1940
|
+
return filtered_parameters
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def normalize_invocation_plan(
|
|
1944
|
+
plan: dict[str, Any],
|
|
1945
|
+
conversation: str,
|
|
1946
|
+
metadata: dict[str, dict[str, Any]],
|
|
1947
|
+
*,
|
|
1948
|
+
selection_hints: list[dict[str, Any]] | None = None,
|
|
1949
|
+
) -> dict[str, Any]:
|
|
1950
|
+
capability = str(plan.get("selected_capability") or "").strip()
|
|
1951
|
+
if not capability:
|
|
1952
|
+
raise ValueError("Model selected unsupported capability: <empty>")
|
|
1953
|
+
if capability not in metadata:
|
|
1954
|
+
raise ValueError(f"Model selected unsupported capability: {capability}")
|
|
1955
|
+
runtime_selection_hints = runtime_selection_hints_for(metadata)
|
|
1956
|
+
effective_selection_hints = [
|
|
1957
|
+
*(selection_hints or []),
|
|
1958
|
+
*runtime_selection_hints,
|
|
1959
|
+
]
|
|
1960
|
+
user_conversation = user_authored_conversation_text(conversation)
|
|
1961
|
+
parameters = plan.get("parameters")
|
|
1962
|
+
if not isinstance(parameters, dict):
|
|
1963
|
+
raise ValueError("Model returned invalid parameters payload")
|
|
1964
|
+
capability = select_consumable_capability(user_conversation, capability, metadata, effective_selection_hints, parameters)
|
|
1965
|
+
|
|
1966
|
+
normalized_plan = dict(plan)
|
|
1967
|
+
filtered_parameters = normalize_declared_parameters(parameters, user_conversation, metadata[capability])
|
|
1968
|
+
normalized_plan["selected_capability"] = capability
|
|
1969
|
+
normalized_plan["parameters"] = filtered_parameters
|
|
1970
|
+
|
|
1971
|
+
unsupported_effects = requested_unsupported_effects(user_conversation, metadata[capability])
|
|
1972
|
+
missing_required = [
|
|
1973
|
+
str(input_spec.get("name") or "")
|
|
1974
|
+
for input_spec in metadata[capability].get("input_specs", [])
|
|
1975
|
+
if input_spec.get("required") is True
|
|
1976
|
+
and str(input_spec.get("name") or "")
|
|
1977
|
+
and str(input_spec.get("name") or "") not in filtered_parameters
|
|
1978
|
+
]
|
|
1979
|
+
if unsupported_effects:
|
|
1980
|
+
normalized_plan["unsupported"] = True
|
|
1981
|
+
normalized_plan["unsupported_reason"] = (
|
|
1982
|
+
"The selected ANIP capability does not declare support for requested effect(s): "
|
|
1983
|
+
+ ", ".join(sorted(unsupported_effects))
|
|
1984
|
+
)
|
|
1985
|
+
elif normalized_plan.get("unsupported") is True and supported_by_business_language_rule(
|
|
1986
|
+
user_conversation,
|
|
1987
|
+
metadata[capability],
|
|
1988
|
+
unsupported_effects,
|
|
1989
|
+
):
|
|
1990
|
+
normalized_plan["unsupported"] = False
|
|
1991
|
+
normalized_plan["unsupported_reason"] = None
|
|
1992
|
+
elif normalized_plan.get("unsupported") is True and should_clear_planner_unsupported_for_approval_boundary(
|
|
1993
|
+
user_conversation,
|
|
1994
|
+
metadata[capability],
|
|
1995
|
+
parameter_values=filtered_parameters,
|
|
1996
|
+
requested_effects=unsupported_effects,
|
|
1997
|
+
):
|
|
1998
|
+
normalized_plan["unsupported"] = False
|
|
1999
|
+
normalized_plan["unsupported_reason"] = None
|
|
2000
|
+
elif normalized_plan.get("unsupported") is True and should_clear_planner_unsupported_for_declared_effect(
|
|
2001
|
+
user_conversation,
|
|
2002
|
+
metadata[capability],
|
|
2003
|
+
requested_effects=unsupported_effects,
|
|
2004
|
+
):
|
|
2005
|
+
normalized_plan["unsupported"] = False
|
|
2006
|
+
normalized_plan["unsupported_reason"] = None
|
|
2007
|
+
elif (
|
|
2008
|
+
normalized_plan.get("unsupported") is True
|
|
2009
|
+
and not unsupported_effects
|
|
2010
|
+
and _conversation_has_read_intent(user_conversation)
|
|
2011
|
+
and (capability_produces(metadata[capability]) & {"content.summary", "data.aggregate", "data.read"})
|
|
2012
|
+
):
|
|
2013
|
+
normalized_plan["unsupported"] = False
|
|
2014
|
+
normalized_plan["unsupported_reason"] = None
|
|
2015
|
+
elif normalized_plan.get("unsupported") is True and missing_required:
|
|
2016
|
+
normalized_plan["unsupported"] = False
|
|
2017
|
+
normalized_plan["unsupported_reason"] = None
|
|
2018
|
+
return normalized_plan
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def conversation_text_from_history(question: str, history: list[dict[str, Any]] | None = None) -> str:
|
|
2022
|
+
parts: list[str] = []
|
|
2023
|
+
for item in history or []:
|
|
2024
|
+
if not isinstance(item, dict):
|
|
2025
|
+
continue
|
|
2026
|
+
role = str(item.get("role") or "").strip().lower()
|
|
2027
|
+
content = str(item.get("content") or "").strip()
|
|
2028
|
+
if role in {"user", "assistant"} and content:
|
|
2029
|
+
parts.append(f"{role}: {content}")
|
|
2030
|
+
parts.append(f"user: {question}")
|
|
2031
|
+
return "\n".join(parts)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def user_authored_conversation_text(conversation: str) -> str:
|
|
2035
|
+
"""Return only user-authored transcript lines when role prefixes are present."""
|
|
2036
|
+
|
|
2037
|
+
text = str(conversation or "")
|
|
2038
|
+
user_lines: list[str] = []
|
|
2039
|
+
saw_role_prefix = False
|
|
2040
|
+
for line in text.splitlines():
|
|
2041
|
+
stripped = line.strip()
|
|
2042
|
+
lowered = stripped.lower()
|
|
2043
|
+
if lowered.startswith("user:"):
|
|
2044
|
+
saw_role_prefix = True
|
|
2045
|
+
content = stripped.split(":", 1)[1].strip()
|
|
2046
|
+
if content:
|
|
2047
|
+
user_lines.append(f"user: {content}")
|
|
2048
|
+
elif lowered.startswith("assistant:"):
|
|
2049
|
+
saw_role_prefix = True
|
|
2050
|
+
if saw_role_prefix:
|
|
2051
|
+
return "\n".join(user_lines)
|
|
2052
|
+
return text
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def _failure_resolution(anip_result: dict[str, Any]) -> dict[str, Any]:
|
|
2056
|
+
failure = anip_result.get("failure")
|
|
2057
|
+
if not isinstance(failure, dict):
|
|
2058
|
+
return {}
|
|
2059
|
+
resolution = failure.get("resolution")
|
|
2060
|
+
return resolution if isinstance(resolution, dict) else {}
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def _clarification_missing_inputs(
|
|
2064
|
+
failure: dict[str, Any],
|
|
2065
|
+
capability_metadata: dict[str, Any] | None = None,
|
|
2066
|
+
) -> list[str]:
|
|
2067
|
+
resolution = failure.get("resolution") if isinstance(failure.get("resolution"), dict) else {}
|
|
2068
|
+
requires = semantic_text_key(resolution.get("requires") if isinstance(resolution, dict) else "")
|
|
2069
|
+
input_specs = capability_metadata.get("input_specs") if isinstance(capability_metadata, dict) else []
|
|
2070
|
+
matches: list[str] = []
|
|
2071
|
+
if isinstance(input_specs, list):
|
|
2072
|
+
for input_spec in input_specs:
|
|
2073
|
+
if not isinstance(input_spec, dict):
|
|
2074
|
+
continue
|
|
2075
|
+
name = str(input_spec.get("name") or "").strip()
|
|
2076
|
+
if name and semantic_text_key(name) and semantic_text_key(name) in requires:
|
|
2077
|
+
matches.append(name)
|
|
2078
|
+
return matches
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
def build_clarification_continuation(
|
|
2082
|
+
*,
|
|
2083
|
+
capability: str,
|
|
2084
|
+
parameters: dict[str, Any],
|
|
2085
|
+
anip_result: dict[str, Any],
|
|
2086
|
+
capability_metadata: dict[str, Any] | None = None,
|
|
2087
|
+
) -> dict[str, Any] | None:
|
|
2088
|
+
failure = anip_result.get("failure")
|
|
2089
|
+
if not isinstance(failure, dict) or failure.get("type") != "clarification_required":
|
|
2090
|
+
return None
|
|
2091
|
+
resolution = _failure_resolution(anip_result)
|
|
2092
|
+
return {
|
|
2093
|
+
"type": "clarification",
|
|
2094
|
+
"capability": str(capability),
|
|
2095
|
+
"service": capability_metadata.get("service_name") if isinstance(capability_metadata, dict) else None,
|
|
2096
|
+
"parameters": dict(parameters),
|
|
2097
|
+
"missing_inputs": _clarification_missing_inputs(failure, capability_metadata),
|
|
2098
|
+
"requires": resolution.get("requires"),
|
|
2099
|
+
"action": resolution.get("action"),
|
|
2100
|
+
"failure_type": failure.get("type"),
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
def clarification_continuation_from_history(history: list[dict[str, Any]] | None) -> dict[str, Any] | None:
|
|
2105
|
+
for item in reversed(history or []):
|
|
2106
|
+
if not isinstance(item, dict) or str(item.get("role") or "").strip().lower() != "assistant":
|
|
2107
|
+
continue
|
|
2108
|
+
continuation = item.get("continuation")
|
|
2109
|
+
if isinstance(continuation, dict) and continuation.get("type") == "clarification":
|
|
2110
|
+
capability = str(continuation.get("capability") or "").strip()
|
|
2111
|
+
if capability:
|
|
2112
|
+
return continuation
|
|
2113
|
+
return None
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def _input_prompt_summary(capability_metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2117
|
+
summary: list[dict[str, Any]] = []
|
|
2118
|
+
for input_spec in capability_metadata.get("input_specs") or []:
|
|
2119
|
+
if not isinstance(input_spec, dict):
|
|
2120
|
+
continue
|
|
2121
|
+
name = str(input_spec.get("name") or "").strip()
|
|
2122
|
+
if not name:
|
|
2123
|
+
continue
|
|
2124
|
+
entry = {
|
|
2125
|
+
"name": name,
|
|
2126
|
+
"type": input_spec.get("type"),
|
|
2127
|
+
"required": input_spec.get("required") is True,
|
|
2128
|
+
"description": input_spec.get("description"),
|
|
2129
|
+
}
|
|
2130
|
+
if "allowed_values" in input_spec:
|
|
2131
|
+
entry["allowed_values"] = input_spec.get("allowed_values")
|
|
2132
|
+
if isinstance(input_spec.get("resolution"), dict):
|
|
2133
|
+
entry["resolution"] = input_spec.get("resolution")
|
|
2134
|
+
summary.append({key: value for key, value in entry.items() if value not in (None, "", [])})
|
|
2135
|
+
return summary
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def build_clarification_continuation_prompt(
|
|
2139
|
+
*,
|
|
2140
|
+
question: str,
|
|
2141
|
+
continuation: dict[str, Any],
|
|
2142
|
+
capability_metadata: dict[str, Any],
|
|
2143
|
+
) -> str:
|
|
2144
|
+
return (
|
|
2145
|
+
"The previous ANIP invocation returned clarification_required. "
|
|
2146
|
+
"Decide whether the new user message answers that clarification for the same capability.\n"
|
|
2147
|
+
"Return JSON with exactly these fields:\n"
|
|
2148
|
+
"- intent_changed: boolean\n"
|
|
2149
|
+
"- parameters: object containing only declared inputs that the user supplied or corrected\n"
|
|
2150
|
+
"- rationale: short string\n"
|
|
2151
|
+
"- user_message: short string\n\n"
|
|
2152
|
+
"Rules:\n"
|
|
2153
|
+
"- Do not select a new capability unless the user clearly changed intent; set intent_changed=true in that case.\n"
|
|
2154
|
+
"- Do not invent missing values. Only extract values present in the new user message.\n"
|
|
2155
|
+
"- Use only declared input names.\n"
|
|
2156
|
+
"- Preserve the prior capability and prior parameters when intent_changed=false.\n\n"
|
|
2157
|
+
f"Capability: {continuation.get('capability')}\n"
|
|
2158
|
+
f"Declared inputs:\n{json.dumps(_input_prompt_summary(capability_metadata), ensure_ascii=False)}\n"
|
|
2159
|
+
f"Prior parameters:\n{json.dumps(continuation.get('parameters') or {}, ensure_ascii=False)}\n"
|
|
2160
|
+
f"Clarification requires: {continuation.get('requires') or continuation.get('missing_inputs') or 'unspecified'}\n"
|
|
2161
|
+
f"New user message:\n{question}"
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def normalize_clarification_continuation_plan(
|
|
2166
|
+
plan: dict[str, Any],
|
|
2167
|
+
*,
|
|
2168
|
+
conversation: str,
|
|
2169
|
+
continuation: dict[str, Any],
|
|
2170
|
+
capability_metadata: dict[str, Any],
|
|
2171
|
+
) -> dict[str, Any] | None:
|
|
2172
|
+
if plan.get("intent_changed") is True:
|
|
2173
|
+
return None
|
|
2174
|
+
capability = str(continuation.get("capability") or "").strip()
|
|
2175
|
+
if not capability:
|
|
2176
|
+
return None
|
|
2177
|
+
previous_parameters = continuation.get("parameters")
|
|
2178
|
+
proposed_parameters = plan.get("parameters")
|
|
2179
|
+
declared_inputs = {
|
|
2180
|
+
str(input_spec.get("name"))
|
|
2181
|
+
for input_spec in capability_metadata.get("input_specs") or []
|
|
2182
|
+
if isinstance(input_spec, dict) and str(input_spec.get("name") or "").strip()
|
|
2183
|
+
}
|
|
2184
|
+
merged_parameters = (
|
|
2185
|
+
{
|
|
2186
|
+
str(key): value
|
|
2187
|
+
for key, value in previous_parameters.items()
|
|
2188
|
+
if str(key) in declared_inputs and value not in (None, "")
|
|
2189
|
+
}
|
|
2190
|
+
if isinstance(previous_parameters, dict)
|
|
2191
|
+
else {}
|
|
2192
|
+
)
|
|
2193
|
+
if isinstance(proposed_parameters, dict):
|
|
2194
|
+
merged_parameters.update(normalize_declared_parameters(proposed_parameters, conversation, capability_metadata))
|
|
2195
|
+
inferred_parameters = normalize_declared_parameters({}, conversation, capability_metadata)
|
|
2196
|
+
for key, value in inferred_parameters.items():
|
|
2197
|
+
merged_parameters.setdefault(key, value)
|
|
2198
|
+
missing_inputs = [
|
|
2199
|
+
str(item).strip()
|
|
2200
|
+
for item in continuation.get("missing_inputs") or []
|
|
2201
|
+
if str(item or "").strip()
|
|
2202
|
+
]
|
|
2203
|
+
if missing_inputs and any(input_name not in merged_parameters for input_name in missing_inputs):
|
|
2204
|
+
return None
|
|
2205
|
+
return {
|
|
2206
|
+
"selected_capability": capability,
|
|
2207
|
+
"parameters": merged_parameters,
|
|
2208
|
+
"unsupported": False,
|
|
2209
|
+
"unsupported_reason": None,
|
|
2210
|
+
"rationale": plan.get("rationale") or "Continue the previous clarification.",
|
|
2211
|
+
"user_message": plan.get("user_message") or "",
|
|
2212
|
+
"continuation": True,
|
|
2213
|
+
}
|