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.
- gooddata_eval/core/agentic/_clarification.py +511 -0
- gooddata_eval/core/agentic/_conversation_context.py +16 -0
- gooddata_eval/core/agentic/conversation.py +262 -3
- gooddata_eval/core/chat/sse_client.py +30 -4
- gooddata_eval/core/models.py +3 -0
- {gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/METADATA +2 -2
- {gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/RECORD +10 -9
- {gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/WHEEL +0 -0
- {gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/entry_points.txt +0 -0
- {gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
gooddata_eval/core/models.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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=
|
|
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/
|
|
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
|
|
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=
|
|
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.
|
|
81
|
-
gooddata_eval-1.79.
|
|
82
|
-
gooddata_eval-1.79.
|
|
83
|
-
gooddata_eval-1.79.
|
|
84
|
-
gooddata_eval-1.79.
|
|
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,,
|
|
File without changes
|
{gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{gooddata_eval-1.79.1.dev1.dist-info → gooddata_eval-1.79.2.dev1.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|