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.
@@ -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 swallowed: the caller
246
- treats an empty result the same as "this delivery path doesn't match",
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
- return {u.id for u in (resp.data or [])}
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
- def _check_recipients(expected: CatalogMetricAlert, actual_args: dict, sdk: GoodDataSdk | None = None) -> bool:
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, expected.recipients)
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
- recipients = ", ".join(expected.recipients) if expected.recipients else "not specified"
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
- anomaly_rule = (
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
- anomaly_rule = (
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
- "4. Proactively include your email recipient in your first reply. "
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" + anomaly_rule + "Reply concisely and directly."
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, expected, conversation_history, question=question
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 and _check_recipients(expected, actual_args, sdk=sdk),
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.dev1
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.dev1
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=gz8_kb_mCX9ixsVtUijOdeXUpsMoyVbPop0IRoUBfJo,46817
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.dev1.dist-info/METADATA,sha256=oKgH2350n47i3Z-tVktpDUuDoBQzRyGEFjKAddkq-WU,37256
73
- gooddata_eval-1.77.1.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
74
- gooddata_eval-1.77.1.dev1.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
75
- gooddata_eval-1.77.1.dev1.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
76
- gooddata_eval-1.77.1.dev1.dist-info/RECORD,,
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,,