gooddata-eval 1.77.1.dev1__py3-none-any.whl → 1.77.1.dev2__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.
- gooddata_eval/core/agentic/alert_skill.py +124 -17
- {gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/METADATA +2 -2
- {gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/RECORD +6 -6
- {gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
from dataclasses import dataclass, field
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
import httpx
|
|
12
13
|
from gooddata_sdk import GoodDataSdk
|
|
13
14
|
|
|
14
15
|
from gooddata_eval.core.agentic._catalog import AnomalyDetectionGranularity, CatalogMetricAlert
|
|
@@ -54,6 +55,11 @@ _DEFAULT_MAX_ITERATIONS = 6
|
|
|
54
55
|
|
|
55
56
|
_TRIGGER_DISPLAY_TO_API = {"Every time": "ALWAYS", "One time": "ONCE"}
|
|
56
57
|
_ALWAYS_TRIGGER_VALUES = {"Every time", "ALWAYS", "not specified"}
|
|
58
|
+
_RELATIVE_OPERATORS = ("INCREASES_BY", "DECREASES_BY", "CHANGES_BY")
|
|
59
|
+
|
|
60
|
+
# Expected-recipient spellings that mean the owner of the API token.
|
|
61
|
+
_CURRENT_USER_RECIPIENT = "me"
|
|
62
|
+
_CURRENT_USER_MARKERS = {"me", "myself", "current user", "current_user"}
|
|
57
63
|
|
|
58
64
|
_TRIGGER_INSTRUCTIONS = {
|
|
59
65
|
"ALWAYS": (
|
|
@@ -242,9 +248,9 @@ def _resolve_internal_recipient_ids(sdk: GoodDataSdk, emails: list[str]) -> set[
|
|
|
242
248
|
`create_metric_alert` then addresses the alert by internal user id
|
|
243
249
|
(`internal_recipients`), never by email, so an expected email has to be
|
|
244
250
|
resolved before it can be compared against that field. Failures (no
|
|
245
|
-
matching user, no permission, network error) are
|
|
246
|
-
|
|
247
|
-
which is correct -- it doesn't mean the alert itself failed.
|
|
251
|
+
matching user, no permission, network error) are logged and return an
|
|
252
|
+
empty set: the caller treats that the same as "this delivery path doesn't
|
|
253
|
+
match", which is correct -- it doesn't mean the alert itself failed.
|
|
248
254
|
"""
|
|
249
255
|
if not emails:
|
|
250
256
|
return set()
|
|
@@ -254,12 +260,48 @@ def _resolve_internal_recipient_ids(sdk: GoodDataSdk, emails: list[str]) -> set[
|
|
|
254
260
|
escaped = [email.replace("\\", "\\\\").replace("'", "\\'") for email in emails]
|
|
255
261
|
quoted = ",".join(f"'{email}'" for email in escaped)
|
|
256
262
|
resp = sdk._client.entities_api.get_all_entities_users(filter=f"email=in=({quoted})")
|
|
257
|
-
|
|
258
|
-
except Exception:
|
|
263
|
+
ids = {u.id for u in (resp.data or [])}
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
print(f"[RECIPIENTS] Looking up users by {len(emails)} recipient email(s) failed: {exc}")
|
|
259
266
|
return set()
|
|
267
|
+
if not ids:
|
|
268
|
+
print(
|
|
269
|
+
f"[RECIPIENTS] No user has any of the {len(emails)} recipient email(s); internal recipients cannot match."
|
|
270
|
+
)
|
|
271
|
+
return ids
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_current_user_id(host: str, token: str) -> str | None:
|
|
275
|
+
"""Id of the user that owns ``token``, or None if the profile cannot be read.
|
|
276
|
+
|
|
277
|
+
The current user is matched by id, not email: that user may have no email at all.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
resp = httpx.get(
|
|
281
|
+
f"{host.rstrip('/')}/api/v1/profile",
|
|
282
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
283
|
+
timeout=30.0,
|
|
284
|
+
)
|
|
285
|
+
resp.raise_for_status()
|
|
286
|
+
user_id = resp.json().get("userId")
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
print(f"[RECIPIENTS] Resolving the current user failed: {exc}")
|
|
289
|
+
return None
|
|
290
|
+
return user_id if isinstance(user_id, str) and user_id else None
|
|
291
|
+
|
|
260
292
|
|
|
293
|
+
def _split_current_user(recipients: list[str]) -> tuple[bool, list[str]]:
|
|
294
|
+
"""Whether ``recipients`` names the current user, and the email recipients besides it."""
|
|
295
|
+
emails = [r for r in recipients if r != _CURRENT_USER_RECIPIENT]
|
|
296
|
+
return len(emails) != len(recipients), emails
|
|
261
297
|
|
|
262
|
-
|
|
298
|
+
|
|
299
|
+
def _check_recipients(
|
|
300
|
+
expected: CatalogMetricAlert,
|
|
301
|
+
actual_args: dict,
|
|
302
|
+
sdk: GoodDataSdk | None = None,
|
|
303
|
+
current_user_id: str | None = None,
|
|
304
|
+
) -> bool:
|
|
263
305
|
if not expected.recipients:
|
|
264
306
|
return True
|
|
265
307
|
act_recip_raw = actual_args.get("recipients", actual_args.get("external_recipients"))
|
|
@@ -274,8 +316,6 @@ def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: Good
|
|
|
274
316
|
act_recip = act_recip_raw
|
|
275
317
|
else:
|
|
276
318
|
act_recip = []
|
|
277
|
-
if set(expected.recipients) == set(act_recip or []):
|
|
278
|
-
return True
|
|
279
319
|
act_internal_raw = actual_args.get("internal_recipients")
|
|
280
320
|
# internal_recipients is declared `anyOf: [array of string, string, null]` in the
|
|
281
321
|
# create_metric_alert tool schema -- a single id as a bare string is schema-legal,
|
|
@@ -287,8 +327,27 @@ def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: Good
|
|
|
287
327
|
act_internal = act_internal_raw
|
|
288
328
|
else:
|
|
289
329
|
act_internal = []
|
|
330
|
+
wants_current_user, expected_emails = _split_current_user(expected.recipients)
|
|
331
|
+
if wants_current_user:
|
|
332
|
+
if current_user_id is None or current_user_id not in act_internal:
|
|
333
|
+
return False
|
|
334
|
+
other_internal = set(act_internal) - {current_user_id}
|
|
335
|
+
external = set(act_recip or [])
|
|
336
|
+
if external - set(expected_emails):
|
|
337
|
+
return False
|
|
338
|
+
# An email not sent externally must arrive as its user's id (workspace-restricted channel).
|
|
339
|
+
unmatched = [e for e in expected_emails if e not in external]
|
|
340
|
+
if not unmatched:
|
|
341
|
+
return not other_internal
|
|
342
|
+
if sdk is None or not other_internal:
|
|
343
|
+
return False
|
|
344
|
+
# One lookup per email: a bulk lookup returns only the users that exist, hiding a missing one.
|
|
345
|
+
ids_per_email = [_resolve_internal_recipient_ids(sdk, [email]) for email in unmatched]
|
|
346
|
+
return all(ids_per_email) and other_internal == set().union(*ids_per_email)
|
|
347
|
+
if set(expected_emails) == set(act_recip or []):
|
|
348
|
+
return True
|
|
290
349
|
if sdk is not None and act_internal:
|
|
291
|
-
internal_recipient_ids = _resolve_internal_recipient_ids(sdk,
|
|
350
|
+
internal_recipient_ids = _resolve_internal_recipient_ids(sdk, expected_emails)
|
|
292
351
|
if internal_recipient_ids & set(act_internal):
|
|
293
352
|
return True
|
|
294
353
|
return False
|
|
@@ -299,6 +358,7 @@ def generate_simulated_alert_response(
|
|
|
299
358
|
expected: CatalogMetricAlert,
|
|
300
359
|
conversation_history: list,
|
|
301
360
|
question: str = "",
|
|
361
|
+
current_user_id: str | None = None,
|
|
302
362
|
) -> str:
|
|
303
363
|
"""Stateful sim-user reply for alert-skill conversation (gpt-4o).
|
|
304
364
|
|
|
@@ -306,6 +366,8 @@ def generate_simulated_alert_response(
|
|
|
306
366
|
history — the opening question went straight to the agent, never to the sim-user — so
|
|
307
367
|
without it rule 5's "the filters your original request implies" refers to text the model
|
|
308
368
|
cannot see. Optional (defaults to "") to keep the signature backwards compatible.
|
|
369
|
+
|
|
370
|
+
``current_user_id`` lets the sim-user recognise itself as the recipient of a "me" alert.
|
|
309
371
|
"""
|
|
310
372
|
if _OpenAI is None:
|
|
311
373
|
raise RuntimeError(
|
|
@@ -333,7 +395,30 @@ def generate_simulated_alert_response(
|
|
|
333
395
|
threshold = expected.threshold
|
|
334
396
|
else:
|
|
335
397
|
threshold = "not specified"
|
|
336
|
-
|
|
398
|
+
wants_current_user, emails = _split_current_user(expected.recipients)
|
|
399
|
+
recipient_names = list(emails)
|
|
400
|
+
if wants_current_user:
|
|
401
|
+
user_id_note = f", user id {current_user_id}" if current_user_id else ""
|
|
402
|
+
recipient_names.insert(0, f"me (yourself, the current user{user_id_note})")
|
|
403
|
+
recipients = ", ".join(recipient_names) if recipient_names else "not specified"
|
|
404
|
+
if wants_current_user and not emails:
|
|
405
|
+
# An email given here would make the agent send the alert to it instead of the current user.
|
|
406
|
+
recipients_rule = (
|
|
407
|
+
"4. The alert is for you yourself. Never give an email address and never ask for one to "
|
|
408
|
+
"be added. A proposal whose recipient is you — shown as 'You', the current user or your "
|
|
409
|
+
"own user id — has the correct recipient.\n"
|
|
410
|
+
)
|
|
411
|
+
elif wants_current_user:
|
|
412
|
+
recipients_rule = (
|
|
413
|
+
f"4. Proactively state the email recipients in your first reply: {', '.join(emails)}, "
|
|
414
|
+
"and say the alert should also go to you yourself. A recipient shown as 'You', the "
|
|
415
|
+
"current user or your own user id is you.\n"
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
recipients_rule = (
|
|
419
|
+
"4. Proactively include your email recipient in your first reply. "
|
|
420
|
+
" Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
|
|
421
|
+
)
|
|
337
422
|
trigger = expected.trigger
|
|
338
423
|
filters = expected.filters
|
|
339
424
|
|
|
@@ -378,14 +463,25 @@ def generate_simulated_alert_response(
|
|
|
378
463
|
# and scoring an item against an interval it never asked for is the defect this rule
|
|
379
464
|
# exists to undo.
|
|
380
465
|
granularity = (expected.granularity or AnomalyDetectionGranularity.DAY).value
|
|
381
|
-
|
|
466
|
+
period_rule = (
|
|
382
467
|
"7. This is an ANOMALY alert. Anomaly detection REQUIRES a time granularity, and that "
|
|
383
468
|
f"granularity is NOT a date filter. State it in your first reply and repeat it whenever "
|
|
384
469
|
f"asked: use {granularity} granularity. Rule 5 constrains filters on the metric only — it "
|
|
385
470
|
"never applies to this detection interval, so never refuse to give one.\n"
|
|
386
471
|
)
|
|
472
|
+
elif operator in _RELATIVE_OPERATORS:
|
|
473
|
+
# The item names no comparison date or period; delegating is an answer the agent accepts,
|
|
474
|
+
# refusing loops until the turn budget runs out.
|
|
475
|
+
period_rule = (
|
|
476
|
+
"7. This is a comparison alert: keep the comparison your original request describes. The "
|
|
477
|
+
"agent may need a comparison date and a period length (day, week, month, quarter or "
|
|
478
|
+
"year), and those are NOT a date filter. Rule 5 constrains filters on the metric only — "
|
|
479
|
+
"it never applies to the comparison. If the agent asks which date or which period the "
|
|
480
|
+
"comparison should use, do not refuse and do not name one yourself: hand the choice back, "
|
|
481
|
+
"for example 'Pick whichever date and period fit best.'\n"
|
|
482
|
+
)
|
|
387
483
|
else:
|
|
388
|
-
|
|
484
|
+
period_rule = (
|
|
389
485
|
"7. Do not invent an evaluation period, a granularity or an 'evaluate each run on a X "
|
|
390
486
|
"basis' instruction your goal never asked for.\n"
|
|
391
487
|
)
|
|
@@ -409,11 +505,10 @@ def generate_simulated_alert_response(
|
|
|
409
505
|
" when every one of those fields matches your goal.\n"
|
|
410
506
|
" A field your goal reports as 'not specified' is one you have NO expectation about: "
|
|
411
507
|
" accept whatever the agent chose for it and never ask for it to be removed.\n"
|
|
412
|
-
|
|
413
|
-
" Do not wait for the agent to ask — state it alongside the metric and condition answers.\n"
|
|
508
|
+
+ recipients_rule
|
|
414
509
|
+ filters_rule
|
|
415
510
|
+ f"6. Proactively state how often you want to be alerted in your first reply: {trigger_request}. "
|
|
416
|
-
" Repeat it if the agent proposes a different cadence.\n" +
|
|
511
|
+
" Repeat it if the agent proposes a different cadence.\n" + period_rule + "Reply concisely and directly."
|
|
417
512
|
)
|
|
418
513
|
|
|
419
514
|
messages: list = [{"role": "system", "content": system_prompt}]
|
|
@@ -590,6 +685,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
|
|
|
590
685
|
recipients = [r.strip() for r in raw_recip.replace(";", ",").split(",") if r.strip()]
|
|
591
686
|
else:
|
|
592
687
|
recipients = list(raw_recip)
|
|
688
|
+
recipients = [_CURRENT_USER_RECIPIENT if str(r).strip().lower() in _CURRENT_USER_MARKERS else r for r in recipients]
|
|
593
689
|
|
|
594
690
|
filters = _normalize_expected_filters(expected)
|
|
595
691
|
attributes = _normalize_expected_attributes(expected)
|
|
@@ -665,6 +761,12 @@ def run_agentic_alert_skill(
|
|
|
665
761
|
) -> AgenticAlertSummary:
|
|
666
762
|
"""Run the alert-skill agentic evaluation K times and return a summary."""
|
|
667
763
|
expected = _normalize_expected_output(expected_output)
|
|
764
|
+
current_user_id: str | None = None
|
|
765
|
+
if _CURRENT_USER_RECIPIENT in expected.recipients:
|
|
766
|
+
current_user_id = _resolve_current_user_id(host, token)
|
|
767
|
+
if current_user_id is None:
|
|
768
|
+
# A harness fault: report it as an item error, not as the agent's recipient mistake.
|
|
769
|
+
raise RuntimeError("Cannot score a current-user alert: the token owner's user id could not be resolved")
|
|
668
770
|
run_results: list[AlertRunResult] = []
|
|
669
771
|
client = ChatClient(
|
|
670
772
|
host=host,
|
|
@@ -761,7 +863,11 @@ def run_agentic_alert_skill(
|
|
|
761
863
|
# exit reason to classify the run as an error rather than an agent failure.
|
|
762
864
|
try:
|
|
763
865
|
follow_up = generate_simulated_alert_response(
|
|
764
|
-
response_text,
|
|
866
|
+
response_text,
|
|
867
|
+
expected,
|
|
868
|
+
conversation_history,
|
|
869
|
+
question=question,
|
|
870
|
+
current_user_id=current_user_id,
|
|
765
871
|
)
|
|
766
872
|
except Exception as exc: # noqa: BLE001 -- harness-side fault; end only this run
|
|
767
873
|
print(f"[SIM-USER] Simulated reply failed for conversation {conv_id}: {exc}")
|
|
@@ -779,7 +885,8 @@ def run_agentic_alert_skill(
|
|
|
779
885
|
trigger_correct=tool_called and _check_trigger(expected, actual_args),
|
|
780
886
|
filters_correct=tool_called and _check_filters(expected, actual_args),
|
|
781
887
|
metric_correct=tool_called and _check_metric(expected, actual_args),
|
|
782
|
-
recipients_correct=tool_called
|
|
888
|
+
recipients_correct=tool_called
|
|
889
|
+
and _check_recipients(expected, actual_args, sdk=sdk, current_user_id=current_user_id),
|
|
783
890
|
attributes_correct=tool_called and _check_attributes(expected, actual_args),
|
|
784
891
|
granularity_correct=tool_called and _check_granularity(expected, actual_args),
|
|
785
892
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gooddata-eval
|
|
3
|
-
Version: 1.77.1.
|
|
3
|
+
Version: 1.77.1.dev2
|
|
4
4
|
Summary: Evaluate the GoodData AI agent against your own questions and models.
|
|
5
5
|
Project-URL: Source, https://github.com/gooddata/gooddata-python-sdk
|
|
6
6
|
Author-email: GoodData <support@gooddata.com>
|
|
@@ -17,7 +17,7 @@ Classifier: Topic :: Scientific/Engineering
|
|
|
17
17
|
Classifier: Topic :: Software Development
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist: gooddata-sdk~=1.77.1.
|
|
20
|
+
Requires-Dist: gooddata-sdk~=1.77.1.dev2
|
|
21
21
|
Requires-Dist: httpx<1.0,>=0.27
|
|
22
22
|
Requires-Dist: jsonpatch<2.0,>=1.33
|
|
23
23
|
Requires-Dist: orjson<4.0.0,>=3.9.15
|
|
@@ -20,7 +20,7 @@ gooddata_eval/core/agentic/_failed_runs.py,sha256=iXN80ORMrIKEhV1QKGbyEZPP2w__mL
|
|
|
20
20
|
gooddata_eval/core/agentic/_gate.py,sha256=irI4eWOCfmsbKd6l-HdpoAlnWkq2wjo4b5crGU_Er1o,2631
|
|
21
21
|
gooddata_eval/core/agentic/_langfuse.py,sha256=NWhPgpd2NkYmAPBiT8VCOd_cddhhsmDGmcWmsvyGLGs,29729
|
|
22
22
|
gooddata_eval/core/agentic/_trace_linker.py,sha256=mSY_7UDZU97r5qcaDxMoyPQ46944XzbzFbMRd74G87k,15760
|
|
23
|
-
gooddata_eval/core/agentic/alert_skill.py,sha256=
|
|
23
|
+
gooddata_eval/core/agentic/alert_skill.py,sha256=Xfo1R2MoPy_ksldq5bCuZv7OgWuLIcrvtK4B-z8XCSI,52150
|
|
24
24
|
gooddata_eval/core/agentic/anomaly_detection.py,sha256=Y-ycRF1t_yYp3iBi3svo7i3by6hFDRFE-1bzqaYcZkE,27322
|
|
25
25
|
gooddata_eval/core/agentic/conversation.py,sha256=E4YUoTMCveft8Ugn2-P2bjcLZpq28U4zWY5U0hDnpEQ,59205
|
|
26
26
|
gooddata_eval/core/agentic/dashboard_skill.py,sha256=iVf9oRsfF2CeAU3lUmXuvFXnvWVedTKqWhNlJOhVUxg,80465
|
|
@@ -69,8 +69,8 @@ gooddata_eval/core/reporting/json_report.py,sha256=Phg9iwXbnX0nV4szHm-txElsWSoIZ
|
|
|
69
69
|
gooddata_eval/core/reporting/report_template.html,sha256=vLkl95OZsm3dMdijv5zda-15wYPE5UvTs4uKeWnjCiI,22332
|
|
70
70
|
gooddata_eval/core/summary/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
|
|
71
71
|
gooddata_eval/core/summary/http_client.py,sha256=dPArJ6zoCu1W9xmYXFA1WMDNsSOkhn3gaCpYgCUp3gk,2174
|
|
72
|
-
gooddata_eval-1.77.1.
|
|
73
|
-
gooddata_eval-1.77.1.
|
|
74
|
-
gooddata_eval-1.77.1.
|
|
75
|
-
gooddata_eval-1.77.1.
|
|
76
|
-
gooddata_eval-1.77.1.
|
|
72
|
+
gooddata_eval-1.77.1.dev2.dist-info/METADATA,sha256=voc6soZVvuSar6t3YmboYlSF5Mr4BjF9LgCU2SE6x_8,37256
|
|
73
|
+
gooddata_eval-1.77.1.dev2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
74
|
+
gooddata_eval-1.77.1.dev2.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
|
|
75
|
+
gooddata_eval-1.77.1.dev2.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
|
|
76
|
+
gooddata_eval-1.77.1.dev2.dist-info/RECORD,,
|
|
File without changes
|
{gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{gooddata_eval-1.77.1.dev1.dist-info → gooddata_eval-1.77.1.dev2.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|