gooddata-eval 1.79.1.dev1__py3-none-any.whl → 1.79.2.dev1__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.
@@ -0,0 +1,511 @@
1
+ # (C) 2026 GoodData Corporation. All rights reserved.
2
+ """Whether a conversation turn's first reply asked the clarifying question its fixture expects."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import functools
7
+ import re
8
+ from collections.abc import Callable
9
+ from typing import Any, Literal
10
+
11
+ from gooddata_api_client.exceptions import ApiException, NotFoundException
12
+ from gooddata_sdk import GoodDataSdk
13
+ from gooddata_sdk.utils import load_all_entities
14
+ from pydantic import BaseModel, Field, model_validator
15
+ from urllib3.exceptions import HTTPError
16
+
17
+ from gooddata_eval.core._retry import RETRYABLE_STATUS_CODES, retry_transient
18
+ from gooddata_eval.core.models import ChatResult
19
+
20
+ ClarificationForm = Literal["questionnaire", "text"]
21
+
22
+ _REF_RE = re.compile(r"\{([A-Za-z_]+)/([^{}]+)\}")
23
+ # The entity kinds a `{type/id}` option reference can name. An attribute and its label are
24
+ # spelled either way by the agent, so each accepts the other. A date dataset is a dataset.
25
+ _REF_ENTITIES: dict[str, tuple[str, ...]] = {
26
+ "metric": ("metrics",),
27
+ "fact": ("facts",),
28
+ "dataset": ("datasets",),
29
+ "date": ("datasets",),
30
+ "attribute": ("attributes", "labels"),
31
+ "date_attribute": ("attributes", "labels"),
32
+ "label": ("labels", "attributes"),
33
+ "parameter": ("parameters",),
34
+ "dashboard": ("analyticalDashboards",),
35
+ "analyticalDashboard": ("analyticalDashboards",),
36
+ "visualization": ("visualizationObjects",),
37
+ "visualizationObject": ("visualizationObjects",),
38
+ "insight": ("visualizationObjects",),
39
+ }
40
+ # Reference types gen-ai writes that no entity listing holds, so grounding cannot check them.
41
+ _UNLISTED_REF_TYPES = frozenset({"computed_attribute", "filter_context", "document", "report"})
42
+ _ENTITY_KINDS = tuple(sorted({kind for kinds in _REF_ENTITIES.values() for kind in kinds}))
43
+ _PAGE_SIZE = 1000
44
+ # The EntitiesApi listing of each entity kind.
45
+ _LIST_METHODS: dict[str, str] = {
46
+ "analyticalDashboards": "get_all_entities_analytical_dashboards",
47
+ "attributes": "get_all_entities_attributes",
48
+ "datasets": "get_all_entities_datasets",
49
+ "facts": "get_all_entities_facts",
50
+ "labels": "get_all_entities_labels",
51
+ "metrics": "get_all_entities_metrics",
52
+ "parameters": "get_all_entities_parameters",
53
+ "visualizationObjects": "get_all_entities_visualization_objects",
54
+ }
55
+ # A shorter skipped option, such as "No" or "Q1", also occurs in text that assumes nothing, so it
56
+ # never counts as named.
57
+ _MIN_NAMED_OPTION_CHARS = 3
58
+
59
+
60
+ OptionTitleKind = Literal["metric", "attribute", "fact", "dataset", "dashboard", "visualization"]
61
+ _TITLE_ENTITIES: dict[str, str] = {
62
+ "metric": "metrics",
63
+ "attribute": "attributes",
64
+ "fact": "facts",
65
+ "dataset": "datasets",
66
+ "dashboard": "analyticalDashboards",
67
+ "visualization": "visualizationObjects",
68
+ }
69
+ # What may follow a title in an option that adds context, as two charts with one title need.
70
+ _TITLE_CONTEXT_SEPARATORS = (" (", " [", " - ", " – ", " — ", ": ", ", ")
71
+ _GENERIC_ANSWERS = frozenset(
72
+ {"yes", "no", "ok", "okay", "cancel", "confirm", "continue", "proceed", "skip", "not now", "go ahead"}
73
+ )
74
+ # How an option may spell a chart type whose `local:<type>` id is not a word of its own.
75
+ _CHART_TYPE_WORDS: dict[str, tuple[str, ...]] = {
76
+ "combo2": ("combo",),
77
+ "pushpin": ("geo", "map", "pushpin"),
78
+ "heatmap": ("heatmap", "heat map"),
79
+ "treemap": ("treemap", "tree map"),
80
+ "dependencywheel": ("dependency wheel",),
81
+ }
82
+
83
+
84
+ class QuestionOptions(BaseModel):
85
+ """One question's options: the ``type/id`` references they name, and the options that name none."""
86
+
87
+ refs: list[str] = Field(default_factory=list)
88
+ plain: list[str] = Field(default_factory=list)
89
+
90
+
91
+ class FirstReply(BaseModel):
92
+ """What the turn's first assistant reply asked, read before any simulated-user answer."""
93
+
94
+ asked: bool
95
+ form: ClarificationForm | None = None
96
+ questions: list[QuestionOptions] = Field(default_factory=list)
97
+
98
+ @property
99
+ def question_count(self) -> int:
100
+ return len(self.questions)
101
+
102
+ @property
103
+ def option_refs(self) -> list[str]:
104
+ return [ref for q in self.questions for ref in q.refs]
105
+
106
+ @property
107
+ def plain_options(self) -> list[str]:
108
+ return [option for q in self.questions for option in q.plain]
109
+
110
+ @property
111
+ def has_options(self) -> bool:
112
+ return any(q.refs or q.plain for q in self.questions)
113
+
114
+
115
+ def read_first_reply(chat_result: ChatResult, *, asked_in_text: bool) -> FirstReply:
116
+ """The reply's clarifying questions; a ``clarifyingQuestions`` part makes it a questionnaire.
117
+
118
+ ``asked_in_text`` is the caller's reading of the prose, for a question without a part.
119
+ """
120
+ parts = [p for p in chat_result.unhandled_parts or [] if p.get("type") == "clarifyingQuestions"]
121
+ if not parts:
122
+ return FirstReply(asked=asked_in_text, form="text" if asked_in_text else None)
123
+ questions = []
124
+ for question in (q for p in parts for q in p.get("questions") or []):
125
+ options = _options(question)
126
+ questions.append(
127
+ QuestionOptions(
128
+ refs=[f"{kind}/{obj_id}" for text in options for kind, obj_id in _REF_RE.findall(text)],
129
+ plain=[text for text in options if not _REF_RE.search(text)],
130
+ )
131
+ )
132
+ return FirstReply(asked=True, form="questionnaire", questions=questions)
133
+
134
+
135
+ class WorkspaceObjects(BaseModel):
136
+ """The workspace's objects as the option checks read them: ``<entity kind>/<id>`` and their titles."""
137
+
138
+ refs: set[str] = Field(default_factory=set)
139
+ # `<entity kind>/<id>` -> the title, casefolded with whitespace collapsed.
140
+ titles: dict[str, str] = Field(default_factory=dict)
141
+ # `visualizationObjects/<id>` -> the chart type, `local:donut` read as `donut`.
142
+ chart_types: dict[str, str] = Field(default_factory=dict)
143
+
144
+ def titles_of(self, entity_kind: str) -> set[str]:
145
+ return {title for ref, title in self.titles.items() if ref.startswith(f"{entity_kind}/")}
146
+
147
+
148
+ def _title_key(text: str) -> str:
149
+ return " ".join(text.casefold().split())
150
+
151
+
152
+ def _is_transient(exc: Exception) -> bool:
153
+ if isinstance(exc, ApiException):
154
+ return exc.status in RETRYABLE_STATUS_CODES
155
+ return isinstance(exc, HTTPError)
156
+
157
+
158
+ def _page_with_retry(list_page: Callable[..., Any], **page: int) -> Any:
159
+ return retry_transient(lambda: list_page(**page), is_retryable=_is_transient)
160
+
161
+
162
+ def fetch_workspace_objects(sdk: GoodDataSdk, workspace_id: str) -> WorkspaceObjects:
163
+ """Every ``<entity kind>/<id>`` and title the workspace holds, inherited objects included.
164
+
165
+ A page that fails with a transient error is retried as a chat message is. A kind the server does
166
+ not know (404) has no objects. Any other failure of the entity API is raised.
167
+ """
168
+ found = WorkspaceObjects()
169
+ api = sdk._client.entities_api
170
+ for kind in _ENTITY_KINDS:
171
+ list_page = functools.partial(getattr(api, _LIST_METHODS[kind]), workspace_id, _check_return_type=False)
172
+ try:
173
+ data = load_all_entities(functools.partial(_page_with_retry, list_page), page_size=_PAGE_SIZE).data
174
+ except NotFoundException:
175
+ continue
176
+ for obj in data:
177
+ if not isinstance(obj, dict) or not obj.get("id"):
178
+ continue
179
+ ref = f"{kind}/{obj['id']}"
180
+ found.refs.add(ref)
181
+ raw_attributes = obj.get("attributes")
182
+ attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
183
+ title = attributes.get("title")
184
+ if isinstance(title, str) and title:
185
+ found.titles[ref] = _title_key(title)
186
+ raw_content = attributes.get("content")
187
+ content: dict[str, Any] = raw_content if isinstance(raw_content, dict) else {}
188
+ chart_url = str(content.get("visualizationUrl") or "")
189
+ if chart_url.startswith("local:"):
190
+ found.chart_types[ref] = chart_url.removeprefix("local:")
191
+ return found
192
+
193
+
194
+ def ungrounded_refs(refs: list[str], known: set[str]) -> list[str]:
195
+ """The references that name no object of the workspace, or a kind it has none of.
196
+
197
+ A type of ``_UNLISTED_REF_TYPES`` is left unchecked.
198
+ """
199
+ missing: list[str] = []
200
+ for ref in refs:
201
+ ref_type, _, obj_id = ref.partition("/")
202
+ if ref_type in _UNLISTED_REF_TYPES:
203
+ continue
204
+ kinds = _REF_ENTITIES.get(ref_type, ())
205
+ if not any(f"{kind}/{obj_id}" in known for kind in kinds):
206
+ missing.append(ref)
207
+ return missing
208
+
209
+
210
+ def _is_titled(option: str, titles: set[str]) -> bool:
211
+ key = _title_key(option)
212
+ return key in titles or any(key.startswith(title + sep) for title in titles for sep in _TITLE_CONTEXT_SEPARATORS)
213
+
214
+
215
+ def untitled_options(options: list[str], objects: WorkspaceObjects, kind: OptionTitleKind) -> list[str]:
216
+ """The options that are not the title of a ``kind`` object, alone or followed by added context."""
217
+ titles = objects.titles_of(_TITLE_ENTITIES[kind])
218
+ return [option for option in options if not _is_titled(option, titles)]
219
+
220
+
221
+ def _off_in_scope(
222
+ questions: list[QuestionOptions], off_of: Callable[[QuestionOptions], tuple[int, list[str]]]
223
+ ) -> list[str]:
224
+ """The options an option check fails, read only from the questions the check is about.
225
+
226
+ ``off_of`` gives a question's count of options the check reads and the ones it fails. A questionnaire
227
+ can ask about more than one thing, such as a metric next to a channel: a question with at least one
228
+ option the check accepts is about it, and a question with none is about something else and left out.
229
+ When no question is about it, every option the check read fails.
230
+ """
231
+ checked = [off_of(q) for q in questions]
232
+ about = [off for read, off in checked if len(off) < read]
233
+ return [option for off in (about or [off for _, off in checked]) for option in off]
234
+
235
+
236
+ def untitled_question_options(reply: FirstReply, objects: WorkspaceObjects, kind: OptionTitleKind) -> list[str]:
237
+ """The plain-text options that are not the title of a ``kind`` object, in the questions about that kind."""
238
+ return _off_in_scope(reply.questions, lambda q: (len(q.plain), untitled_options(q.plain, objects, kind)))
239
+
240
+
241
+ def _entity_refs(ref: str) -> set[str]:
242
+ ref_type, _, obj_id = ref.partition("/")
243
+ return {f"{kind}/{obj_id}" for kind in _REF_ENTITIES.get(ref_type, ())}
244
+
245
+
246
+ def is_object_ref(ref: str) -> bool:
247
+ """Whether ``ref`` is ``type/id`` with a type an option reference can name."""
248
+ ref_type, sep, obj_id = ref.partition("/")
249
+ return bool(sep and obj_id) and ref_type in _REF_ENTITIES
250
+
251
+
252
+ def _names_chart_type(option: str, chart_type: str) -> bool:
253
+ key = _title_key(option)
254
+ return any(re.search(rf"\b{re.escape(word)}\b", key) for word in _CHART_TYPE_WORDS.get(chart_type, (chart_type,)))
255
+
256
+
257
+ def off_candidate_options(reply: FirstReply, objects: WorkspaceObjects, candidates: list[str]) -> list[str]:
258
+ """The options that name none of ``candidates``, in the questions that offer at least one of them.
259
+
260
+ An option names a candidate by reference, by its title plus context, or by its chart type when
261
+ no other candidate has that type: "Donut chart" tells two same-titled charts apart only then.
262
+ A chart type does not count in an option that starts with the title of an object outside the candidates.
263
+ """
264
+ wanted = set().union(*(_entity_refs(c) for c in candidates))
265
+ titles = {objects.titles[ref] for ref in wanted if ref in objects.titles}
266
+ kinds = {ref.partition("/")[0] for ref in wanted}
267
+ other_titles = {
268
+ title for ref, title in objects.titles.items() if ref not in wanted and ref.partition("/")[0] in kinds
269
+ } - titles
270
+ types = [objects.chart_types[ref] for ref in wanted if ref in objects.chart_types]
271
+ unique_types = {chart_type for chart_type in types if types.count(chart_type) == 1}
272
+
273
+ def names_a_candidate_type(option: str) -> bool:
274
+ return any(_names_chart_type(option, t) for t in unique_types) and not _is_titled(option, other_titles)
275
+
276
+ def off_of(question: QuestionOptions) -> tuple[int, list[str]]:
277
+ off = [ref for ref in question.refs if not (_entity_refs(ref) & wanted)]
278
+ off += [
279
+ option for option in question.plain if not _is_titled(option, titles) and not names_a_candidate_type(option)
280
+ ]
281
+ return len(question.refs) + len(question.plain), off
282
+
283
+ return _off_in_scope(reply.questions, off_of)
284
+
285
+
286
+ def clarification_mismatches(
287
+ reply: FirstReply,
288
+ *,
289
+ expects_clarification: bool,
290
+ expected_form: ClarificationForm | None,
291
+ max_questions: int | None,
292
+ objects: WorkspaceObjects | None,
293
+ grounded: bool,
294
+ titled_as: OptionTitleKind | None,
295
+ candidates: list[str] | None = None,
296
+ reads_questionnaire: bool = False,
297
+ ) -> list[str]:
298
+ """How the first reply differs from the expected clarification; empty when it matches.
299
+
300
+ ``grounded`` checks the ``{type/id}`` options of every question; ``titled_as`` checks the plain-text
301
+ ones against that kind's titles; ``candidates`` requires every option to name one of those objects.
302
+ The last two read only the questions they are about (see ``_off_in_scope``). All three are skipped
303
+ when ``objects`` is None: the caller reports the turn ungraded then.
304
+ ``reads_questionnaire`` fails a prose question, which has no options for these checks to read.
305
+ """
306
+ if not expects_clarification:
307
+ return [f"asked a clarifying question ({reply.form}) the request does not need"] if reply.asked else []
308
+ if not reply.asked:
309
+ return ["expected a clarifying question, but the first reply asked none"]
310
+ out: list[str] = []
311
+ if expected_form is not None and reply.form != expected_form:
312
+ out.append(f"clarification form: expected {expected_form}, got {reply.form}")
313
+ elif reads_questionnaire and reply.form != "questionnaire":
314
+ out.append(f"the turn checks a questionnaire, but the first reply asked as {reply.form}")
315
+ if max_questions is not None and reply.question_count > max_questions:
316
+ out.append(f"asked {reply.question_count} questions, at most {max_questions} expected")
317
+ if objects is not None and grounded:
318
+ missing = ungrounded_refs(reply.option_refs, objects.refs)
319
+ if missing:
320
+ out.append(f"options name objects the workspace does not have: {missing}")
321
+ if objects is not None and titled_as is not None:
322
+ untitled = untitled_question_options(reply, objects, titled_as)
323
+ if untitled:
324
+ out.append(f"options are not the title of a {titled_as} of the workspace: {untitled}")
325
+ if objects is not None and candidates:
326
+ off = off_candidate_options(reply, objects, candidates)
327
+ if off:
328
+ out.append(f"options name none of {candidates}: {off}")
329
+ return out
330
+
331
+
332
+ class ClarifyingPick(BaseModel):
333
+ """An option the fixture picks in a questionnaire.
334
+
335
+ ``option`` is a ``type/id`` reference the option names, or the option's whole text. A list names
336
+ the spellings of one option, since the agent writes it as a reference or as plain text.
337
+ ``question`` limits the pick to that position; without it, the first question offering the
338
+ option takes it.
339
+ """
340
+
341
+ option: str | list[str]
342
+ question: int | None = Field(default=None, ge=0)
343
+
344
+ @property
345
+ def spellings(self) -> list[str]:
346
+ return [self.option] if isinstance(self.option, str) else list(self.option)
347
+
348
+ @property
349
+ def label(self) -> str:
350
+ return " | ".join(self.spellings)
351
+
352
+ @model_validator(mode="after")
353
+ def _names_an_option(self) -> ClarifyingPick:
354
+ if not self.spellings or not all(s.strip() for s in self.spellings):
355
+ raise ValueError("option needs at least one non-empty spelling")
356
+ return self
357
+
358
+
359
+ class ClarifyingAnswers(BaseModel):
360
+ """The ``clarifyingAnswers`` for one questionnaire, and what they left out."""
361
+
362
+ answers: list[dict] = Field(default_factory=list)
363
+ skipped: list[dict] = Field(default_factory=list)
364
+ matched_picks: list[str] = Field(default_factory=list)
365
+
366
+ def render(self) -> str:
367
+ """The text gen-ai builds for the model from these answers: every question, a skipped one as Skipped."""
368
+ sections = [(a["questionIndex"], a["questionText"], a["answer"]["optionText"]) for a in self.answers]
369
+ sections += [(s["index"], s["text"], "Skipped") for s in self.skipped]
370
+ return "\n\n".join(f"**{text}**\n\n{answer}" for _, text, answer in sorted(sections, key=lambda s: s[0]))
371
+
372
+
373
+ def _all_questions(chat_result: ChatResult) -> list[dict]:
374
+ parts = [p for p in chat_result.unhandled_parts or [] if p.get("type") == "clarifyingQuestions"]
375
+ return [q for p in parts for q in p.get("questions") or []]
376
+
377
+
378
+ def _questions(chat_result: ChatResult) -> list[dict]:
379
+ """The questions of the item the answers name, numbered as gen-ai numbers them: across that item's parts."""
380
+ item_id = chat_result.clarifying_questions_item_id
381
+ parts = [
382
+ p
383
+ for p in chat_result.unhandled_parts or []
384
+ if p.get("type") == "clarifyingQuestions" and p.get("itemId") in (None, item_id)
385
+ ]
386
+ return [q for p in parts for q in p.get("questions") or []]
387
+
388
+
389
+ def _options(question: dict) -> list[str]:
390
+ return [str(o.get("text") or "") for o in (question.get("control") or {}).get("options") or []]
391
+
392
+
393
+ def _plain(text: str) -> str:
394
+ return " ".join(_REF_RE.sub(" ", text).lower().split())
395
+
396
+
397
+ def _same_object(ref: str, other: str) -> bool:
398
+ return ref == other or bool(_entity_refs(ref) & _entity_refs(other))
399
+
400
+
401
+ def option_matches(option_text: str, wanted: str) -> bool:
402
+ """Whether an option is the one a pick names, by its ``{type/id}`` reference or its whole text.
403
+
404
+ A reference matches the spellings grounding accepts for the same object, such as ``attribute/x`` and ``label/x``.
405
+ """
406
+ ref = wanted.strip().strip("{}")
407
+ if "/" in ref and any(_same_object(ref, f"{kind}/{obj_id}") for kind, obj_id in _REF_RE.findall(option_text)):
408
+ return True
409
+ return option_text.strip().lower() == wanted.strip().lower()
410
+
411
+
412
+ def build_clarifying_answers(chat_result: ChatResult, picks: list[ClarifyingPick]) -> ClarifyingAnswers:
413
+ """Answer the reply's questionnaire with the picks; a question no pick matches is skipped."""
414
+ questions = _questions(chat_result)
415
+ out = ClarifyingAnswers()
416
+ answered: set[int] = set()
417
+ for pick in picks:
418
+ for qi, question in enumerate(questions):
419
+ if qi in answered or (pick.question is not None and pick.question != qi):
420
+ continue
421
+ oi = next(
422
+ (
423
+ i
424
+ for i, text in enumerate(_options(question))
425
+ if any(option_matches(text, spelling) for spelling in pick.spellings)
426
+ ),
427
+ None,
428
+ )
429
+ if oi is None:
430
+ continue
431
+ answered.add(qi)
432
+ out.matched_picks.append(pick.label)
433
+ out.answers.append(
434
+ {
435
+ "questionIndex": qi,
436
+ "questionText": question.get("text") or "",
437
+ "answer": {"type": "singleChoice", "optionIndex": oi, "optionText": _options(question)[oi]},
438
+ }
439
+ )
440
+ break
441
+ out.answers.sort(key=lambda a: a["questionIndex"])
442
+ out.skipped = [
443
+ {"index": qi, "text": q.get("text") or "", "options": _options(q)}
444
+ for qi, q in enumerate(questions)
445
+ if qi not in answered
446
+ ]
447
+ return out
448
+
449
+
450
+ def _names_option(text: str, option: str) -> bool:
451
+ """Whether ``text`` names the option: one of its references whole, or its words as whole words."""
452
+ refs_in_text = [f"{kind}/{obj_id}" for kind, obj_id in _REF_RE.findall(text)]
453
+ for kind, obj_id in _REF_RE.findall(option):
454
+ if any(_same_object(f"{kind}/{obj_id}", ref) for ref in refs_in_text):
455
+ return True
456
+ label = _plain(option)
457
+ return (
458
+ len(label) >= _MIN_NAMED_OPTION_CHARS
459
+ and re.search(rf"(?<!\w){re.escape(label)}(?!\w)", _plain(text)) is not None
460
+ )
461
+
462
+
463
+ def _same_question(question: dict, skipped: dict) -> bool:
464
+ """Whether ``question`` asks ``skipped`` again: the same text, or the same options that name something.
465
+
466
+ Options such as Yes and No fit any question, a confirmation too, so they count only with the same text.
467
+ """
468
+ if _plain(question.get("text") or "") == _plain(skipped["text"]):
469
+ return True
470
+ options = sorted(_options(question))
471
+ specific = any(_plain(option).strip(".!") not in _GENERIC_ANSWERS for option in options)
472
+ return specific and options == sorted(skipped["options"])
473
+
474
+
475
+ def after_answers_mismatches(
476
+ *,
477
+ unmatched_picks: list[str],
478
+ skipped: list[dict],
479
+ replies_after: list[tuple[str, ChatResult, bool]],
480
+ states_skipped_assumption: bool,
481
+ skipped_questions_not_reasked: bool,
482
+ expects_confirmation_after_answers: bool,
483
+ ) -> list[str]:
484
+ """How the turn after its structured answers differs from the fixture; empty when it matches.
485
+
486
+ ``replies_after`` holds each later reply with its kind (``output`` or a ``classify_reply`` kind) and
487
+ whether it answers a ``clarifyingAnswers`` message directly.
488
+
489
+ ``states_skipped_assumption`` stands in for "the agent says what it assumed": it passes when any reply
490
+ after the answers names an option of each skipped question. It cannot tell an assumption from a
491
+ mention, and it does not need the assumption in the final reply, since a reply that asks for
492
+ confirmation usually states it.
493
+ """
494
+ out = [f"no questionnaire offered the picked option {pick!r}" for pick in unmatched_picks]
495
+ if states_skipped_assumption:
496
+ texts = [reply.text_response or "" for _, reply, _ in replies_after]
497
+ out.extend(
498
+ f"no reply after the answers names an option of the skipped question {question['text']!r}"
499
+ for question in skipped
500
+ if not any(_names_option(text, option) for text in texts for option in question["options"])
501
+ )
502
+ if skipped_questions_not_reasked:
503
+ for question in skipped:
504
+ asked_again = any(
505
+ _same_question(later, question) for _, reply, _ in replies_after for later in _all_questions(reply)
506
+ )
507
+ if asked_again:
508
+ out.append(f"asked the skipped question {question['text']!r} again")
509
+ if expects_confirmation_after_answers and any(kind == "output" and follows for kind, _, follows in replies_after):
510
+ out.append("acted on the answers in the same reply, without waiting for confirmation")
511
+ return out
@@ -68,6 +68,8 @@ _RETRY_CHOICE_RE = re.compile(
68
68
  # word, so the apostrophes in "don't ... it's" do not mask the text between them.
69
69
  _FORMULA_SPAN_RE = re.compile(r"`+[^`]*`+|\{[^{}]*\}|\"[^\"\n]*\"|(?<!\w)'[^'\n]*'(?!\w)")
70
70
 
71
+ # Where one sentence ends and the next starts, for asks_in_text.
72
+ _SENTENCE_END_RE = re.compile(r"(?<=[.?!])\s+")
71
73
  # Checked after _CONFIRMATION_RE, so "please confirm" stays a confirmation.
72
74
  _REQUEST_RE = re.compile(
73
75
  r"\bplease\s+(?:select|choose|pick|specify|provide|tell\s+me|let\s+me\s+know|clarify|share)\b"
@@ -106,6 +108,20 @@ def classify_reply(chat_result: ChatResult, rendered_text: str) -> ReplyKind:
106
108
  return "no_action"
107
109
 
108
110
 
111
+ def asks_in_text(chat_result: ChatResult, rendered_text: str) -> bool:
112
+ """Whether a reply without the expected output asks the user something, read for the clarification checks.
113
+
114
+ ``classify_reply`` reads "Did you mean A or B? Please confirm." as a confirmation, which is right for
115
+ what the simulated user says back. Here it is a question: a sentence that ends in a question mark and
116
+ is not itself a confirmation phrase asks, whatever confirmation follows it.
117
+ """
118
+ kind = classify_reply(chat_result, rendered_text)
119
+ if kind != "confirmation" or chat_result.alert_proposals:
120
+ return kind == "question"
121
+ sentences = _SENTENCE_END_RE.split(rendered_text.strip())
122
+ return any(s.rstrip().endswith("?") and not _CONFIRMATION_RE.search(s) for s in sentences)
123
+
124
+
109
125
  @dataclass
110
126
  class TranscriptEntry:
111
127
  role: Literal["user", "assistant"]
@@ -27,13 +27,28 @@ from typing import ClassVar, Literal
27
27
  from gooddata_sdk import GoodDataSdk
28
28
  from pydantic import BaseModel, Field, ValidationError, model_validator
29
29
 
30
+ from gooddata_eval.core.agentic._clarification import (
31
+ ClarificationForm,
32
+ ClarifyingPick,
33
+ FirstReply,
34
+ OptionTitleKind,
35
+ WorkspaceObjects,
36
+ after_answers_mismatches,
37
+ build_clarifying_answers,
38
+ clarification_mismatches,
39
+ fetch_workspace_objects,
40
+ is_object_ref,
41
+ read_first_reply,
42
+ )
30
43
  from gooddata_eval.core.agentic._conversation_context import (
31
44
  CONFIRMATION_REPLY,
32
45
  NUDGE_MESSAGE,
33
46
  ClarificationJudge,
34
47
  TranscriptEntry,
48
+ asks_in_text,
35
49
  classify_reply,
36
50
  clip,
51
+ has_structured_question,
37
52
  judge_prompt,
38
53
  render_transcript,
39
54
  reply_from_facts,
@@ -108,6 +123,27 @@ class TurnDefinition(BaseModel):
108
123
  and setting it to null clears it. A turn that leaves it out keeps the current one. Its
109
124
  clarification replies carry it too, since gen-ai treats a message without a context as
110
125
  cleared.
126
+
127
+ ``expects_clarification`` grades the turn's first reply: true means it must ask, false means
128
+ it must not. Left out, nothing is checked. With true, ``expected_clarification_form`` names how
129
+ it asks (``questionnaire`` = a ``clarifyingQuestions`` part, ``text`` = prose only),
130
+ ``max_clarifying_questions`` caps the questionnaire, and ``clarifying_options_grounded``
131
+ requires every ``{type/id}`` option to name an object of the workspace. ``clarifying_option_titles``
132
+ names an object kind (``visualization``, ``metric``, ...): every plain-text option must be the title
133
+ of such an object, alone or followed by context in brackets or after a dash, comma or colon.
134
+ ``clarifying_option_objects`` lists the ``type/id`` objects the question is about, such as the
135
+ charts sharing the title the user named: every option must name one of them, by reference, by
136
+ its title with that context, or by its chart type when no other candidate has that type.
137
+
138
+ ``clarifying_answers`` answers the questionnaire of the turn's first reply the way the chat panel
139
+ does, with a ``clarifyingAnswers`` message: a question no pick matches is skipped, and an empty
140
+ list skips them all. Every other question, a later questionnaire included, gets ``set_answers``
141
+ text, so a follow-up confirmation can be answered. With it,
142
+ ``states_skipped_assumption`` requires a reply after the answers to name an option of each skipped
143
+ question (a mention, which stands in for the stated assumption),
144
+ ``skipped_questions_not_reasked`` forbids a later questionnaire asking one again, and
145
+ ``expects_confirmation_after_answers`` requires the agent to stop after each ``clarifyingAnswers``
146
+ message rather than act on it in the same reply: a skip is not an approval.
111
147
  """
112
148
 
113
149
  turn_id: str
@@ -121,6 +157,66 @@ class TurnDefinition(BaseModel):
121
157
  depends_on: list[str] = Field(default_factory=list)
122
158
  set_answers: list[str] = Field(default_factory=list)
123
159
  user_context: dict | None = None
160
+ expects_clarification: bool | None = None
161
+ expected_clarification_form: ClarificationForm | None = None
162
+ max_clarifying_questions: int | None = Field(default=None, ge=1)
163
+ clarifying_options_grounded: bool = False
164
+ clarifying_option_titles: OptionTitleKind | None = None
165
+ clarifying_option_objects: list[str] | None = None
166
+ clarifying_answers: list[ClarifyingPick] | None = None
167
+ states_skipped_assumption: bool = False
168
+ skipped_questions_not_reasked: bool = False
169
+ expects_confirmation_after_answers: bool = False
170
+
171
+ @property
172
+ def reads_questionnaire(self) -> bool:
173
+ """Whether a clarification key of the turn reads a ``clarifyingQuestions`` part, so prose cannot satisfy it."""
174
+ return (
175
+ self.max_clarifying_questions is not None
176
+ or self.clarifying_options_grounded
177
+ or self.clarifying_option_titles is not None
178
+ or self.clarifying_option_objects is not None
179
+ or self.clarifying_answers is not None
180
+ )
181
+
182
+ @model_validator(mode="after")
183
+ def _clarification_keys_need_an_expected_question(self) -> TurnDefinition:
184
+ # Each of these only reads a question, so without one it would pass vacuously.
185
+ stated = [
186
+ name
187
+ for name, value in (
188
+ ("expected_clarification_form", self.expected_clarification_form),
189
+ ("max_clarifying_questions", self.max_clarifying_questions),
190
+ ("clarifying_options_grounded", self.clarifying_options_grounded or None),
191
+ ("clarifying_option_titles", self.clarifying_option_titles),
192
+ ("clarifying_option_objects", self.clarifying_option_objects),
193
+ ("clarifying_answers", self.clarifying_answers),
194
+ )
195
+ if value is not None
196
+ ]
197
+ if stated and self.expects_clarification is not True:
198
+ raise ValueError(f"{stated} need expects_clarification: true")
199
+ if self.reads_questionnaire and self.expected_clarification_form == "text":
200
+ raise ValueError(
201
+ "max_clarifying_questions, clarifying_options_grounded, clarifying_option_titles, "
202
+ "clarifying_option_objects and clarifying_answers read a questionnaire, not text"
203
+ )
204
+ after_answers = [
205
+ name
206
+ for name, value in (
207
+ ("states_skipped_assumption", self.states_skipped_assumption),
208
+ ("skipped_questions_not_reasked", self.skipped_questions_not_reasked),
209
+ ("expects_confirmation_after_answers", self.expects_confirmation_after_answers),
210
+ )
211
+ if value
212
+ ]
213
+ if self.clarifying_option_objects is not None:
214
+ bad = [ref for ref in self.clarifying_option_objects if not is_object_ref(ref)]
215
+ if not self.clarifying_option_objects or bad:
216
+ raise ValueError(f"clarifying_option_objects needs type/id references, got {bad or 'none'}")
217
+ if after_answers and self.clarifying_answers is None:
218
+ raise ValueError(f"{after_answers} read the turn after its clarifying_answers; set clarifying_answers")
219
+ return self
124
220
 
125
221
 
126
222
  class ConversationFixture(BaseModel):
@@ -196,6 +292,11 @@ class TurnResult(BaseModel):
196
292
  mismatches: list[str] = Field(default_factory=list)
197
293
  clarifications: list[ClarificationRecord] = Field(default_factory=list)
198
294
  depends_on: list[str] = Field(default_factory=list)
295
+ # None when the fixture states no expects_clarification, or the check could not run.
296
+ clarification_correct: bool | None = None
297
+ clarification_mismatches: list[str] = Field(default_factory=list)
298
+ # Why a stated clarification check could not run, so an ungraded turn does not read as a pass.
299
+ clarification_ungraded: str | None = None
199
300
 
200
301
  @property
201
302
  def skill_success(self) -> bool:
@@ -211,7 +312,13 @@ class TurnResult(BaseModel):
211
312
 
212
313
  @property
213
314
  def context_success(self) -> bool:
214
- return self.skill_success and self.output_correct is not False and not self.lost_context and not self.stalled
315
+ return (
316
+ self.skill_success
317
+ and self.output_correct is not False
318
+ and self.clarification_correct is not False
319
+ and not self.lost_context
320
+ and not self.stalled
321
+ )
215
322
 
216
323
  def failure_reasons(self) -> list[str]:
217
324
  """Why ``context_success`` is False, in reading order; empty when it is True."""
@@ -224,6 +331,8 @@ class TurnResult(BaseModel):
224
331
  reasons.append(f"skill {self.expected_skill!r} not active")
225
332
  if self.output_correct is False:
226
333
  reasons.extend(self.mismatches or ["output does not match the expected output"])
334
+ if self.clarification_correct is False:
335
+ reasons.extend(self.clarification_mismatches)
227
336
  if self.lost_context:
228
337
  reasons.append("asked for information the conversation already established")
229
338
  if self.stalled:
@@ -258,6 +367,13 @@ class TurnResult(BaseModel):
258
367
  out = self.model_dump(include=self._DETAIL_FIELDS)
259
368
  out["context_success"] = self.context_success
260
369
  out["failure_reasons"] = self.failure_reasons()
370
+ # Only when the fixture states the check, so an item without one reports what it always did.
371
+ if self.clarification_correct is not None:
372
+ out["clarification_correct"] = self.clarification_correct
373
+ out["clarification_mismatches"] = list(self.clarification_mismatches)
374
+ elif self.clarification_ungraded is not None:
375
+ out["clarification_correct"] = None
376
+ out["clarification_ungraded"] = self.clarification_ungraded
261
377
  return out
262
378
 
263
379
 
@@ -669,6 +785,10 @@ class _WorkspaceObjects:
669
785
  self.updated_metrics: list[str] = []
670
786
  self.alerts: list[str] = []
671
787
 
788
+ def state(self) -> tuple[tuple[str, ...], ...]:
789
+ """What the conversation has written so far; a change means a workspace listing is out of date."""
790
+ return tuple(self.metrics), tuple(self.updated_metrics), tuple(self.alerts)
791
+
672
792
  def record(self, tool_call_events: list[ToolCallEvent]) -> None:
673
793
  for metric_id, created_new in _metric_creations(tool_call_events):
674
794
  if created_new:
@@ -733,8 +853,17 @@ def run_agentic_conversation(
733
853
 
734
854
  ``user_context`` is the context attached when the conversation starts; a turn's own
735
855
  ``user_context`` replaces it from that turn on.
856
+
857
+ A fixture with ``expects_clarification`` needs ``context`` mode: legacy mode neither sends
858
+ ``clarifying_answers`` nor lets the clarification checks fail the item, so it raises ``ValueError``.
736
859
  """
737
860
  resolved_mode = resolve_conversation_mode(mode)
861
+ graded = [t.turn_id for t in fixture.turns if t.expects_clarification is not None]
862
+ if resolved_mode == "legacy" and graded:
863
+ raise ValueError(
864
+ f"turns {graded} set expects_clarification, which only context mode grades; "
865
+ f"pass mode='context' or set {CONVERSATION_MODE_ENV_VAR}=context"
866
+ )
738
867
  if fresh_conversation_per_turn and initial_conversation_id is not None:
739
868
  raise ValueError("fresh_conversation_per_turn needs conversations this run creates itself")
740
869
  judge = clarification_judge if clarification_judge is not None else ClarificationJudge()
@@ -786,6 +915,9 @@ def run_agentic_conversation(
786
915
 
787
916
  run_started = time.monotonic()
788
917
  conversation_swap_s = 0.0 # fresh_conversation_per_turn's delete/create, which is not the run's work
918
+ workspace_read_s = 0.0 # the option checks' workspace listing, which is not the run's work either
919
+ # The option checks' listing, with the created.state() it was read at; read again once that changes.
920
+ workspace_listing: tuple[tuple[tuple[str, ...], ...], WorkspaceObjects] | None = None
789
921
  for turn_index, turn in enumerate(fixture.turns):
790
922
  if fresh_conversation_per_turn and turn_index > 0:
791
923
  swap_started = time.monotonic()
@@ -842,6 +974,15 @@ def run_agentic_conversation(
842
974
  final_result: ChatResult | None = None
843
975
  records: list[ClarificationRecord] = []
844
976
  answers_left = list(turn.set_answers)
977
+ first_reply: FirstReply | None = None
978
+ # Structured answers: the one to send next, what they skipped, and every reply after them.
979
+ pending_answers: tuple[str, list[dict]] | None = None
980
+ answers_sent = False
981
+ # Why clarifying_answers were not sent, when the loop knows it better than what is left after.
982
+ answers_unsent: str | None = None
983
+ matched_picks: set[str] = set()
984
+ skipped_questions: list[dict] = []
985
+ replies_after_answers: list[tuple[str, ChatResult, bool]] = []
845
986
 
846
987
  # Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop
847
988
  # that simply runs out of range() is labelled correctly with no trailing else.
@@ -849,8 +990,19 @@ def run_agentic_conversation(
849
990
  for _iter in range(max_clarification_turns + 1):
850
991
  transcript.append(TranscriptEntry("user", current_message))
851
992
  incomplete = False
993
+ follows_answers = pending_answers is not None
994
+ is_first_reply = False
852
995
  try:
853
- chat_result = client.send_message(conversation_id, current_message, user_context=current_context)
996
+ if pending_answers is not None:
997
+ questions_item_id, answers = pending_answers
998
+ pending_answers = None
999
+ chat_result = client.send_clarifying_answers(
1000
+ conversation_id, questions_item_id, answers, user_context=current_context
1001
+ )
1002
+ else:
1003
+ chat_result = client.send_message(
1004
+ conversation_id, current_message, user_context=current_context
1005
+ )
854
1006
  except TurnIncompleteError as exc:
855
1007
  chat_result = exc.partial_result or ChatResult()
856
1008
  incomplete = True
@@ -910,10 +1062,30 @@ def run_agentic_conversation(
910
1062
  "assistant", " ".join(p for p in (response_text, summarize_visualizations(chat_result)) if p)
911
1063
  )
912
1064
  )
1065
+ # A reply the server cut short is a stall the loop nudges past, so the next one is the first reply.
1066
+ if first_reply is None and not incomplete:
1067
+ asked_in_text = not _check_output_present(resolved_turn, chat_result) and asks_in_text(
1068
+ chat_result, response_text
1069
+ )
1070
+ first_reply = read_first_reply(chat_result, asked_in_text=asked_in_text)
1071
+ is_first_reply = True
1072
+ # The questionnaire clarifying_answers would answer, when this reply carries it.
1073
+ answerable = (
1074
+ turn.clarifying_answers is not None and is_first_reply and has_structured_question(chat_result)
1075
+ )
1076
+ if answers_sent:
1077
+ reply_kind = (
1078
+ "output"
1079
+ if _check_output_present(resolved_turn, chat_result)
1080
+ else classify_reply(chat_result, response_text)
1081
+ )
1082
+ replies_after_answers.append((reply_kind, chat_result, follows_answers))
913
1083
 
914
1084
  if _check_output_present(resolved_turn, chat_result):
915
1085
  if incomplete:
916
1086
  records.append(ClarificationRecord(kind="turn_incomplete", agent_message=clip(response_text)))
1087
+ if answerable:
1088
+ answers_unsent = "the first reply delivered the output with its questionnaire, so nothing waited on the answers"
917
1089
  turn_exit = LoopExit.SUCCESS
918
1090
  break
919
1091
 
@@ -948,6 +1120,11 @@ def run_agentic_conversation(
948
1120
  and any(r.kind in ("no_action", "turn_incomplete") for r in records)
949
1121
  )
950
1122
  if clarification_turns >= max_clarification_turns or second_stall:
1123
+ if answerable:
1124
+ answers_unsent = (
1125
+ f"the clarification budget ({max_clarification_turns}) ended the turn "
1126
+ "before clarifying_answers were sent"
1127
+ )
951
1128
  records.append(record)
952
1129
  break
953
1130
 
@@ -957,6 +1134,15 @@ def run_agentic_conversation(
957
1134
  reply = CONFIRMATION_REPLY
958
1135
  elif kind in ("no_action", "turn_incomplete"):
959
1136
  reply = NUDGE_MESSAGE
1137
+ # Before restating: the fixture expects this question, so a lost_context verdict on it
1138
+ # must not send the picks to a later questionnaire.
1139
+ elif answerable and turn.clarifying_answers is not None and chat_result.clarifying_questions_item_id:
1140
+ built = build_clarifying_answers(chat_result, turn.clarifying_answers)
1141
+ matched_picks.update(built.matched_picks)
1142
+ skipped_questions = built.skipped
1143
+ pending_answers = (chat_result.clarifying_questions_item_id, built.answers)
1144
+ answers_sent = True
1145
+ reply = built.render()
960
1146
  elif record.lost_context is True and not any(r.lost_context is True for r in records):
961
1147
  # Once per turn, so a judge mistake cannot loop the user on restating.
962
1148
  reply = reply_restating(render_transcript(transcript[:-1]), response_text)
@@ -964,6 +1150,8 @@ def run_agentic_conversation(
964
1150
  reply = answers_left.pop(0)
965
1151
  else:
966
1152
  reply = reply_from_facts(render_transcript(transcript[:-1]), response_text, turn.set_answers)
1153
+ if answerable and not answers_sent:
1154
+ answers_unsent = "the first reply's questionnaire has no itemId to send clarifying_answers to"
967
1155
  record.reply = clip(reply)
968
1156
  records.append(record)
969
1157
 
@@ -988,6 +1176,67 @@ def run_agentic_conversation(
988
1176
  if final_result and output_present:
989
1177
  output_correct, mismatches = _check_output_correct(resolved_turn, final_result, all_tool_calls)
990
1178
 
1179
+ # A turn with no complete reply has nothing to read; its chat error or missing output fails it.
1180
+ clarification_correct: bool | None = None
1181
+ clarification_problems: list[str] = []
1182
+ clarification_ungraded: str | None = None
1183
+ if turn.expects_clarification is not None and first_reply is not None:
1184
+ checks_options = (
1185
+ turn.clarifying_options_grounded
1186
+ or turn.clarifying_option_titles is not None
1187
+ or turn.clarifying_option_objects is not None
1188
+ )
1189
+ workspace_objects: WorkspaceObjects | None = None
1190
+ # Only a questionnaire with options has anything to check. The listing is kept for the run
1191
+ # and read again once a turn has written to the workspace, since an option can name an
1192
+ # object an earlier turn created.
1193
+ if checks_options and first_reply.has_options:
1194
+ written = created.state()
1195
+ if workspace_listing is None or workspace_listing[0] != written:
1196
+ read_started = time.monotonic()
1197
+ try:
1198
+ workspace_listing = (written, fetch_workspace_objects(sdk, workspace_id))
1199
+ except Exception as exc: # any failure leaves the options unchecked, never passed
1200
+ workspace_listing = None
1201
+ clarification_ungraded = f"workspace objects not readable, options left unchecked: {exc}"
1202
+ print(f"[CLARIFICATION] {clarification_ungraded}")
1203
+ workspace_read_s += time.monotonic() - read_started
1204
+ if workspace_listing is not None:
1205
+ workspace_objects = workspace_listing[1]
1206
+ clarification_problems = clarification_mismatches(
1207
+ first_reply,
1208
+ expects_clarification=turn.expects_clarification,
1209
+ expected_form=turn.expected_clarification_form,
1210
+ max_questions=turn.max_clarifying_questions,
1211
+ objects=workspace_objects,
1212
+ grounded=turn.clarifying_options_grounded,
1213
+ titled_as=turn.clarifying_option_titles,
1214
+ candidates=turn.clarifying_option_objects,
1215
+ reads_questionnaire=turn.reads_questionnaire,
1216
+ )
1217
+ if turn.clarifying_answers is not None and not answers_sent:
1218
+ # Every check after the answers would pass with nothing to read.
1219
+ if answers_unsent is None:
1220
+ answers_unsent = (
1221
+ "the first reply asked no questionnaire to answer"
1222
+ if first_reply.form != "questionnaire"
1223
+ else "the turn ended before clarifying_answers were sent"
1224
+ )
1225
+ clarification_problems.append(f"clarifying_answers not sent: {answers_unsent}")
1226
+ if turn.clarifying_answers is not None and answers_sent:
1227
+ clarification_problems += after_answers_mismatches(
1228
+ unmatched_picks=[p.label for p in turn.clarifying_answers if p.label not in matched_picks],
1229
+ skipped=skipped_questions,
1230
+ replies_after=replies_after_answers,
1231
+ states_skipped_assumption=turn.states_skipped_assumption,
1232
+ skipped_questions_not_reasked=turn.skipped_questions_not_reasked,
1233
+ expects_confirmation_after_answers=turn.expects_confirmation_after_answers,
1234
+ )
1235
+ if clarification_problems:
1236
+ clarification_correct = False
1237
+ elif clarification_ungraded is None:
1238
+ clarification_correct = True
1239
+
991
1240
  # Capture metric output for $ref resolution in subsequent turns.
992
1241
  if final_result and turn.expected_output_type == "metric":
993
1242
  metric_data = _extract_metric_result(all_tool_calls)
@@ -1012,11 +1261,14 @@ def run_agentic_conversation(
1012
1261
  mismatches=mismatches,
1013
1262
  clarifications=records,
1014
1263
  depends_on=list(turn.depends_on),
1264
+ clarification_correct=clarification_correct,
1265
+ clarification_mismatches=clarification_problems,
1266
+ clarification_ungraded=clarification_ungraded,
1015
1267
  )
1016
1268
  )
1017
1269
 
1018
1270
  # Taken here, not after the finally: its cleanup is not part of the run.
1019
- run_latency_s = time.monotonic() - run_started - conversation_swap_s
1271
+ run_latency_s = time.monotonic() - run_started - conversation_swap_s - workspace_read_s
1020
1272
  finally:
1021
1273
  if owns_conversation and conversation_id:
1022
1274
  client.delete_conversation(conversation_id)
@@ -1208,6 +1460,13 @@ def evaluate_agentic_conversation(
1208
1460
  value=float(tr.skill_success),
1209
1461
  data_type="BOOLEAN",
1210
1462
  )
1463
+ if tr.clarification_correct is not None:
1464
+ ctx.score(
1465
+ tid,
1466
+ name=f"turn_{tr.turn_id}_clarification_correct",
1467
+ value=float(tr.clarification_correct),
1468
+ data_type="BOOLEAN",
1469
+ )
1211
1470
  strict_checks = {
1212
1471
  "conversation_success": result.conversation_success,
1213
1472
  "full_skill_coverage": result.full_skill_coverage,
@@ -206,6 +206,7 @@ class _SseAccumulator:
206
206
  alert_proposals: list[dict[str, Any]] = field(default_factory=list)
207
207
  search_results: list[dict[str, Any]] = field(default_factory=list)
208
208
  unhandled_parts: list[dict[str, Any]] = field(default_factory=list)
209
+ clarifying_questions_item_id: str | None = None
209
210
  tool_call_events: list[dict[str, Any]] = field(default_factory=list)
210
211
  call_id_to_event_index: dict[str, int] = field(default_factory=dict)
211
212
  reasoning_steps: list[dict[str, Any]] = field(default_factory=list)
@@ -226,7 +227,8 @@ def _handle_text(content: dict[str, Any], acc: _SseAccumulator) -> None:
226
227
  acc.text_parts.append(text)
227
228
 
228
229
 
229
- def _handle_multipart(content: dict[str, Any], acc: _SseAccumulator) -> None:
230
+ def _handle_multipart(content: dict[str, Any], acc: _SseAccumulator, item_id: str | None = None) -> None:
231
+ """Sort the item's parts; a ``clarifyingQuestions`` part keeps ``item_id``, since answers number questions per item."""
230
232
  for part in content.get("parts", []):
231
233
  ptype = part.get("type")
232
234
  if ptype == "text":
@@ -247,7 +249,8 @@ def _handle_multipart(content: dict[str, Any], acc: _SseAccumulator) -> None:
247
249
  else:
248
250
  if ptype not in _KNOWN_PART_TYPES:
249
251
  _log.warning("unknown multipart part type %r; captured as an unhandled part", ptype)
250
- acc.unhandled_parts.append(part)
252
+ tagged = ptype == "clarifyingQuestions" and item_id
253
+ acc.unhandled_parts.append({**part, "itemId": item_id} if tagged else part)
251
254
 
252
255
 
253
256
  def _handle_reasoning(content: dict[str, Any], acc: _SseAccumulator) -> None:
@@ -300,6 +303,7 @@ def _build_chat_result(acc: _SseAccumulator) -> ChatResult:
300
303
  "alertProposals": acc.alert_proposals,
301
304
  "searchResults": acc.search_results,
302
305
  "unhandledParts": acc.unhandled_parts,
306
+ "clarifyingQuestionsItemId": acc.clarifying_questions_item_id,
303
307
  "toolCallEvents": acc.tool_call_events,
304
308
  "reasoningStepCount": len(acc.reasoning_steps),
305
309
  "reasoningSteps": [step["summary"] for step in acc.reasoning_steps],
@@ -439,7 +443,9 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
439
443
  if ctype == "text":
440
444
  _handle_text(content, acc)
441
445
  elif ctype == "multipart":
442
- _handle_multipart(content, acc)
446
+ _handle_multipart(content, acc, item_id=item.get("itemId"))
447
+ if any(p.get("type") == "clarifyingQuestions" for p in content.get("parts", [])):
448
+ acc.clarifying_questions_item_id = item.get("itemId") or acc.clarifying_questions_item_id
443
449
  elif ctype == "reasoning":
444
450
  _handle_reasoning(content, acc)
445
451
  elif ctype == "toolCall":
@@ -540,11 +546,31 @@ class ChatClient:
540
546
 
541
547
  def send_message(
542
548
  self, conversation_id: str, question: str, *, user_context: dict[str, Any] | None = None
549
+ ) -> ChatResult:
550
+ return self._send(conversation_id, {"type": "text", "text": question}, user_context=user_context)
551
+
552
+ def send_clarifying_answers(
553
+ self,
554
+ conversation_id: str,
555
+ questions_item_id: str,
556
+ answers: list[dict[str, Any]],
557
+ *,
558
+ user_context: dict[str, Any] | None = None,
559
+ ) -> ChatResult:
560
+ """Answer a questionnaire the way the chat panel does: positions plus the texts shown.
561
+
562
+ A question left out of ``answers`` is skipped; an empty list skips them all.
563
+ """
564
+ content = {"type": "clarifyingAnswers", "questionsItemId": questions_item_id, "answers": answers}
565
+ return self._send(conversation_id, content, user_context=user_context)
566
+
567
+ def _send(
568
+ self, conversation_id: str, content: dict[str, Any], *, user_context: dict[str, Any] | None = None
543
569
  ) -> ChatResult:
544
570
  url = f"{self._base}/{conversation_id}/messages"
545
571
  join_headers, joined = self._join_item_trace(conversation_id)
546
572
  headers = {**self._auth, "Accept": "text/event-stream", "Content-Type": "application/json", **join_headers}
547
- body: dict[str, Any] = {"item": {"role": "user", "content": {"type": "text", "text": question}}}
573
+ body: dict[str, Any] = {"item": {"role": "user", "content": content}}
548
574
  options: dict[str, str] = {}
549
575
  if self._reasoning_effort is not None:
550
576
  options["reasoningEffort"] = self._reasoning_effort
@@ -351,6 +351,9 @@ class ChatResult(BaseModel):
351
351
  alert_proposals: list[dict] = Field(default_factory=list, alias="alertProposals")
352
352
  search_results: list[dict] = Field(default_factory=list, alias="searchResults")
353
353
  unhandled_parts: list[dict] = Field(default_factory=list, alias="unhandledParts")
354
+ # The itemId of the assistant item holding a clarifyingQuestions part: a clarifyingAnswers
355
+ # message names it as questionsItemId.
356
+ clarifying_questions_item_id: str | None = Field(default=None, alias="clarifyingQuestionsItemId")
354
357
  tool_call_events: list[ToolCallEvent] = Field(default_factory=list, alias="toolCallEvents")
355
358
  reasoning_step_count: int = Field(default=0, alias="reasoningStepCount")
356
359
  reasoning_steps: list[str] = Field(default_factory=list, alias="reasoningSteps")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gooddata-eval
3
- Version: 1.79.1.dev1
3
+ Version: 1.79.2.dev1
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.79.1.dev1
20
+ Requires-Dist: gooddata-sdk~=1.79.2.dev1
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
@@ -10,7 +10,7 @@ gooddata_eval/core/config.py,sha256=hla5t294uHC5Q9bzrhPjsdkwEcetLsEH-GqGAhs94b4,
10
10
  gooddata_eval/core/connection.py,sha256=mh-Do5-Ti0-DXXikfCIH10eTTUbKuZUAS_sLoPNDJ5I,1229
11
11
  gooddata_eval/core/granularity.py,sha256=CbniwgxtVj0oyf41UjeeZbBzOpeisSBlAfdkL79e6O0,3932
12
12
  gooddata_eval/core/interaction.py,sha256=PUaJ1zdMwXBKjoNXNFXVgt_b8_i0LWdbMZndEQ3F6UM,2093
13
- gooddata_eval/core/models.py,sha256=uYKDfGUqVFfCzEDPHwjlJNvBjjataLBy-W0oMprx9e8,25458
13
+ gooddata_eval/core/models.py,sha256=Dzpdq7jkPJ2foDg7Wjx__S8emNPyKVihoDFLpwi5iQ4,25698
14
14
  gooddata_eval/core/number_format.py,sha256=bQv3YqlMtBHNSMZx2_qGMWXFDdeXNkAGVg3WFIKUd-k,9264
15
15
  gooddata_eval/core/runner.py,sha256=AXgx3MJsFs1CKFogggJwa6Lrt4owRPjQVCr6pt1WJHo,21022
16
16
  gooddata_eval/core/scoring.py,sha256=buY7ZoO08Dt59m2PPhFeNOyix5yYlf4mVxFyOn-aibk,23778
@@ -18,14 +18,15 @@ gooddata_eval/core/timing.py,sha256=1grXpr48OH4GcAmeobhjwnRWqoMJwcjj-LLpHdS_raY,
18
18
  gooddata_eval/core/workspace.py,sha256=S8exiy5gpDtcRtT8-i2fIRALF34Pbo0J46xm8X_ldus,11846
19
19
  gooddata_eval/core/agentic/__init__.py,sha256=i2g22ousTYXi5FZeN5X0WVpa67RQXThwli8KcYfIYxM,4432
20
20
  gooddata_eval/core/agentic/_catalog.py,sha256=My4SP5DteSJlp2WOQ66iemJkX3ANmdqrsgANDEm6u10,4194
21
- gooddata_eval/core/agentic/_conversation_context.py,sha256=43ryGOQ9kTLLrzRxBphUn_87290vTZQ6oqWLnzjKYbA,16270
21
+ gooddata_eval/core/agentic/_clarification.py,sha256=X3XZrF-Y1GsvYpv3UacBeQw-vzIsXBlNjMYWEeyyLJA,22651
22
+ gooddata_eval/core/agentic/_conversation_context.py,sha256=vh5mUgRs4h-tn2039dn47xkQfa3s-RXwDWZW_UeXAas,17175
22
23
  gooddata_eval/core/agentic/_failed_runs.py,sha256=iXN80ORMrIKEhV1QKGbyEZPP2w__mLPSrkm3hh-dTwM,3048
23
24
  gooddata_eval/core/agentic/_gate.py,sha256=irI4eWOCfmsbKd6l-HdpoAlnWkq2wjo4b5crGU_Er1o,2631
24
25
  gooddata_eval/core/agentic/_langfuse.py,sha256=NWhPgpd2NkYmAPBiT8VCOd_cddhhsmDGmcWmsvyGLGs,29729
25
26
  gooddata_eval/core/agentic/_trace_linker.py,sha256=mSY_7UDZU97r5qcaDxMoyPQ46944XzbzFbMRd74G87k,15760
26
27
  gooddata_eval/core/agentic/alert_skill.py,sha256=Me7YrAAGWG6iqzJR18JdhVsri4LG4J1itIUdzgboZU0,60105
27
28
  gooddata_eval/core/agentic/anomaly_detection.py,sha256=H_rKx6fCX3S11HzGAQGhy092YMmGj4foFBmrx6U-LHE,27701
28
- gooddata_eval/core/agentic/conversation.py,sha256=_OLlST1twnSPnOQGfuuFCFKu92uwLVh2YBBA_nOW-5w,60004
29
+ gooddata_eval/core/agentic/conversation.py,sha256=-qHSwIsrBqQOlYNe1GcMSGGb3HGZqdXG3dSLNglC8oo,75510
29
30
  gooddata_eval/core/agentic/dashboard_skill.py,sha256=9ts12qLwxYQUZLYmUhDlxbxCkIiTgr4JdyElF2-J3dA,98041
30
31
  gooddata_eval/core/agentic/document_skill.py,sha256=Uukqc6voa3lXbcSVgfzQyPH_kEFu3jrbrN9EDIMIuYA,45231
31
32
  gooddata_eval/core/agentic/general_question.py,sha256=pOVYlJF9eKZwTqdPVsrH4X1OC-tXI7CMCFDdPNdBIMw,17758
@@ -38,7 +39,7 @@ gooddata_eval/core/agentic/visualization.py,sha256=o4RVS-nRCVbVXjbE_jHuGzgW8x3hR
38
39
  gooddata_eval/core/agentic/what_if.py,sha256=v5u9Yl45QGpVD2IHvPhVW_XOixI10IK4N-5_zP9Yymw,26365
39
40
  gooddata_eval/core/chat/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
40
41
  gooddata_eval/core/chat/render.py,sha256=CqsKN8__DlaiJj_bmcPZ1poc5LfUqtz7EzKRDbks4gU,2082
41
- gooddata_eval/core/chat/sse_client.py,sha256=yGUbWg7gr7ycVfEpvpmGUCIQ38nW0jiMmuJB9_va4UY,31921
42
+ gooddata_eval/core/chat/sse_client.py,sha256=fIWOmw3KEChiwmzS4I21Y_b7VXzDecqoGLbJUWfbOkU,33373
42
43
  gooddata_eval/core/dataset/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
43
44
  gooddata_eval/core/dataset/from_insights.py,sha256=2eYh_84070t2BMeW2XPLAwzrJs7vKbFYMeCppvkDiEo,40880
44
45
  gooddata_eval/core/dataset/langfuse_source.py,sha256=II-6fenEPhdyfckrL0iVdJpavjv67wUvy7OIuvHntrg,8697
@@ -77,8 +78,8 @@ gooddata_eval/core/summary/http_client.py,sha256=Umae42fiiJQ0e4hv63NRROHYvcFAGgX
77
78
  gooddata_eval/core/takeaway/__init__.py,sha256=U54lANKm34yjqm7dZL9KkGouPbwAaQWC9wiAmb5B5g4,32
78
79
  gooddata_eval/core/takeaway/evaluate.py,sha256=ei7DLSlG_Ove_R551Ofdw8WISf178XknU58jBWxB_is,11585
79
80
  gooddata_eval/core/takeaway/http_client.py,sha256=yc3FNPArjfcSdAE7naRf7g_aW7BbJhwsfSEvAFd67Ik,4141
80
- gooddata_eval-1.79.1.dev1.dist-info/METADATA,sha256=9_msqZ1neRnpLckeQYdvR85BjuB6sXpdCe09WSgWzdE,60351
81
- gooddata_eval-1.79.1.dev1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
82
- gooddata_eval-1.79.1.dev1.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
83
- gooddata_eval-1.79.1.dev1.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
84
- gooddata_eval-1.79.1.dev1.dist-info/RECORD,,
81
+ gooddata_eval-1.79.2.dev1.dist-info/METADATA,sha256=ghQfKEWHtHeBM_0gSg6YXuPJzGtnoPiXkYZ_g_pdSe8,60351
82
+ gooddata_eval-1.79.2.dev1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
83
+ gooddata_eval-1.79.2.dev1.dist-info/entry_points.txt,sha256=28nFp5Viknx4haPYgzK9QHlwPFfC6rPF6RmjC2ht8EI,56
84
+ gooddata_eval-1.79.2.dev1.dist-info/licenses/LICENSE.txt,sha256=LVfVlC9maU3K9lgMwdZnClQQsOIbOekdcdVKr9qzJtk,256836
85
+ gooddata_eval-1.79.2.dev1.dist-info/RECORD,,