Skip to content

Commit ebe45f3

Browse files
authored
Merge pull request #1836 from gooddata/QA-29448-multiturn-context-eval
test: improve multiturn conversation eval test
2 parents 6848b82 + ee39a75 commit ebe45f3

7 files changed

Lines changed: 1739 additions & 89 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
# (C) 2026 GoodData Corporation. All rights reserved.
2+
"""What the conversation evaluator needs to judge context, not just output presence.
3+
4+
Three questions get asked of every assistant reply that did not produce the turn's
5+
expected output:
6+
7+
1. What kind of reply is it -- a confirmation request ("Should I create this metric?"),
8+
a question to the user, or a turn that ended without doing anything (a stall)?
9+
2. For a question: did the assistant ask for something the conversation had already
10+
established? That is the lost-context signal, graded by a binary LLM judge.
11+
3. What does the simulated user say back? In ``context`` mode it never sees the turn's
12+
expected output -- only the fixture's set answers and the conversation itself -- so a
13+
lost context can no longer be repaired silently by a user who knows the answer key.
14+
15+
Only information that is IN the conversation counts. A question about something the
16+
assistant could have looked up in the workspace, but that nobody mentioned, is graded as
17+
legitimate: whether the assistant should have looked it up is a separate check.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import hashlib
23+
import json
24+
import os
25+
import re
26+
from dataclasses import dataclass
27+
from typing import Literal
28+
29+
from gooddata_eval.core.config import judge_model
30+
from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError, LLMJudge, _message_content
31+
from gooddata_eval.core.models import ChatResult
32+
33+
ReplyKind = Literal["confirmation", "question", "no_action"]
34+
35+
NUDGE_MESSAGE = "Please go ahead and do it."
36+
CONFIRMATION_REPLY = "Yes, go ahead."
37+
# Asked to pick from a list (which dashboard to bind an alert to), a real user picks one.
38+
NO_ANSWER_REPLY = "Use your best judgement and go ahead. If you need me to pick from a list, take the first option."
39+
40+
# Part of the metric and alert skills' designed flow, not a clarification.
41+
_CONFIRMATION_RE = re.compile(
42+
r"\b(?:should|shall|may|can)\s+i\s+(?:go\s+ahead|proceed|create|save|update|apply|add|set\s+(?:it|this)\s+up)"
43+
r"|\bdo\s+you\s+want\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
44+
r"|\bwould\s+you\s+like\s+me\s+to\s+(?:go\s+ahead|proceed|create|save|update|apply)"
45+
r"|\bplease\s+confirm\b|\bconfirm\s+(?:if|whether|that)\b",
46+
re.IGNORECASE,
47+
)
48+
49+
# Checked after _CONFIRMATION_RE, so "please confirm" stays a confirmation.
50+
_REQUEST_RE = re.compile(
51+
r"\bplease\s+(?:select|choose|pick|specify|provide|tell\s+me|let\s+me\s+know|clarify|share)\b"
52+
r"|\breply\s+with\b|\b(?:select|choose|pick)\s+(?:one|a|an|the)\b",
53+
re.IGNORECASE,
54+
)
55+
56+
_MESSAGE_CHARS = 800
57+
_TRANSCRIPT_MESSAGES = 40
58+
59+
60+
def has_structured_question(chat_result: ChatResult) -> bool:
61+
"""gen-ai's own signal that the reply is a question: a ``clarifyingQuestions`` part."""
62+
return any(part.get("type") == "clarifyingQuestions" for part in chat_result.unhandled_parts or [])
63+
64+
65+
def classify_reply(chat_result: ChatResult, rendered_text: str) -> ReplyKind:
66+
"""Kind of a reply that did not produce the expected output.
67+
68+
The structured ``clarifyingQuestions`` part is authoritative when present. Without it
69+
the text decides: a confirmation phrase or an alert proposal is a confirmation, a
70+
question mark is a question, and anything else -- including an empty reply -- is a turn
71+
that stopped without acting.
72+
"""
73+
if has_structured_question(chat_result):
74+
return "question"
75+
if chat_result.alert_proposals:
76+
return "confirmation"
77+
text = (rendered_text or "").strip()
78+
if not text:
79+
return "no_action"
80+
if _CONFIRMATION_RE.search(text):
81+
return "confirmation"
82+
if "?" in text or _REQUEST_RE.search(text):
83+
return "question"
84+
return "no_action"
85+
86+
87+
@dataclass
88+
class TranscriptEntry:
89+
role: Literal["user", "assistant"]
90+
text: str
91+
92+
93+
def clip(text: str, limit: int = _MESSAGE_CHARS) -> str:
94+
text = " ".join((text or "").split())
95+
return text if len(text) <= limit else text[: limit - 1] + "…"
96+
97+
98+
def summarize_visualizations(chat_result: ChatResult) -> str:
99+
"""One line per chart the reply showed: what it plotted and how it was filtered.
100+
101+
Charts carry most of what a conversation establishes ("the Apparel filter", "by
102+
quarter"), and they are invisible in the text part. The judge needs them to know what
103+
was already settled.
104+
"""
105+
vizzes = chat_result.created_visualizations
106+
objects = getattr(vizzes, "objects", None) or []
107+
lines = []
108+
for viz in objects:
109+
fields = [getattr(f, "using", f) for f in (viz.query.fields or {}).values()]
110+
filters = [
111+
{k: v for k, v in f.items() if k in ("type", "using", "state", "from", "to", "top", "bottom")}
112+
for f in (viz.query.filter_by or {}).values()
113+
if isinstance(f, dict)
114+
]
115+
lines.append(f"[chart {viz.type or ''}: fields={fields} filters={filters}]")
116+
return " ".join(lines)
117+
118+
119+
def render_transcript(entries: list[TranscriptEntry]) -> str:
120+
recent = entries[-_TRANSCRIPT_MESSAGES:]
121+
return "\n".join(f"{e.role.upper()}: {clip(e.text)}" for e in recent)
122+
123+
124+
_JUDGE_SYSTEM = """\
125+
You grade one question that an analytics assistant asked its user. You get:
126+
- CONVERSATION BEFORE: everything said before the user's latest message (may be empty),
127+
- LATEST MESSAGE: what the user just asked,
128+
- QUESTION: the assistant's reply, in which it asks the user something instead of doing it.
129+
130+
Decide whether the information the QUESTION asks for already appears in CONVERSATION BEFORE:
131+
stated by the user, or determined by an assistant answer -- for example which metric was meant,
132+
which filters apply, which item came out weakest or biggest, what a chart showed, which object
133+
was created.
134+
135+
Rules:
136+
- The LATEST MESSAGE does not count. A reference in it ("it", "that second one", "those
137+
brands") only points at something that must appear in CONVERSATION BEFORE.
138+
- If CONVERSATION BEFORE is empty, the answer is false.
139+
- Information that exists in the workspace but was never mentioned in the conversation does not
140+
count.
141+
- Do not judge whether asking was useful, necessary or well phrased.
142+
143+
Examples:
144+
- BEFORE: user "Net sales by country." / assistant "Here is net sales by country." LATEST: "Add
145+
order count to it." QUESTION: "Which chart should I add order count to?" -> true (the chart
146+
just made).
147+
- BEFORE: user "Top 10 products by revenue." / assistant "Here are the top 10 products."
148+
LATEST: "Remove the limit." QUESTION: "Which limit do you mean?" -> true (the top 10).
149+
- BEFORE: (empty) LATEST: "Show revenue by month." QUESTION: "Which revenue metric: gross or
150+
net?" -> false (nothing earlier says which).
151+
152+
Return a JSON object with exactly two keys:
153+
"already_in_conversation": true or false
154+
"reasoning": one sentence naming the information and where it appears, or that it does not
155+
"""
156+
157+
_JUDGE_USER = "CONVERSATION BEFORE:\n{history}\n\nLATEST MESSAGE:\n{latest}\n\nQUESTION:\n{question}"
158+
159+
160+
def judge_prompt(history: str, latest: str, question: str) -> str:
161+
"""The user prompt the clarification judge reads, also recorded for auditing."""
162+
return _JUDGE_USER.format(history=history or "(empty)", latest=latest, question=question)
163+
164+
165+
@dataclass(frozen=True)
166+
class ClarificationVerdict:
167+
lost_context: bool | None # None when the judge could not be run or gave no verdict
168+
reasoning: str
169+
error: str | None = None
170+
171+
172+
class _ClarificationLLM(LLMJudge):
173+
"""``LLMJudge``'s client, temperature handling and retry, with this judge's own prompt and
174+
verdict key. The key names the question asked, so a 0/1 cannot be read the wrong way round."""
175+
176+
def __init__(self, model: str | None = None) -> None:
177+
super().__init__([], model=model)
178+
self._system_prompt = _JUDGE_SYSTEM
179+
180+
def already_in_conversation(self, prompt: str) -> tuple[bool, str]:
181+
messages = [{"role": "system", "content": self._system_prompt}, {"role": "user", "content": prompt}]
182+
raw = None
183+
for _attempt in range(2):
184+
raw = _message_content(self._create_completion(messages))
185+
if isinstance(raw, str) and raw.strip():
186+
break
187+
if not (isinstance(raw, str) and raw.strip()):
188+
raise JudgeResponseError(f"clarification judge {self.model!r} returned an empty body twice")
189+
try:
190+
data = json.loads(raw)
191+
except json.JSONDecodeError as exc:
192+
raise JudgeResponseError(f"clarification judge returned unparseable JSON: {raw!r}") from exc
193+
value = data.get("already_in_conversation") if isinstance(data, dict) else None
194+
if not isinstance(value, bool):
195+
raise JudgeResponseError(f"clarification judge returned no boolean verdict: {raw!r}")
196+
return value, str(data.get("reasoning", ""))
197+
198+
199+
class ClarificationJudge:
200+
"""Binary judge: did the assistant ask for something the conversation already established?
201+
202+
Temperature 0 and a JSON verdict, on ``LLMJudge``'s client. Verdicts are cached per prompt,
203+
so a conversation that repeats the same question does not pay for it twice.
204+
"""
205+
206+
def __init__(self, model: str | None = None) -> None:
207+
self._model = model
208+
self._llm: _ClarificationLLM | None = None
209+
self._cache: dict[str, ClarificationVerdict] = {}
210+
self._warned = False
211+
212+
@property
213+
def model_name(self) -> str:
214+
"""The judge model, without building a client (which needs an API key)."""
215+
return self._llm.model if self._llm is not None else (self._model or judge_model())
216+
217+
def judge(self, history: str, latest: str, question: str) -> ClarificationVerdict:
218+
prompt = judge_prompt(history, latest, question)
219+
key = hashlib.sha256(prompt.encode()).hexdigest()
220+
if key in self._cache:
221+
return self._cache[key]
222+
try:
223+
if self._llm is None:
224+
self._llm = _ClarificationLLM(self._model)
225+
try:
226+
found, reasoning = self._llm.already_in_conversation(prompt)
227+
verdict = ClarificationVerdict(lost_context=found, reasoning=reasoning)
228+
except JudgeResponseError as exc:
229+
verdict = ClarificationVerdict(lost_context=None, reasoning="", error=str(exc))
230+
except Exception as exc: # noqa: BLE001 -- a grading fault must not fail the agent's conversation
231+
if not self._warned:
232+
print(f"[JUDGE] clarifications left unjudged: {exc}")
233+
self._warned = True
234+
verdict = ClarificationVerdict(lost_context=None, reasoning="", error=str(exc))
235+
self._cache[key] = verdict
236+
return verdict
237+
238+
239+
_SIM_USER_MODEL = "gpt-4o"
240+
241+
242+
def _chat(prompt_system: str, prompt_user: str) -> str | None:
243+
try:
244+
from openai import OpenAI # noqa: PLC0415
245+
except ImportError:
246+
return None
247+
api_key = os.environ.get("OPENAI_API_KEY")
248+
if not api_key:
249+
return None
250+
try:
251+
response = OpenAI(api_key=api_key).chat.completions.create(
252+
model=_SIM_USER_MODEL,
253+
messages=[{"role": "system", "content": prompt_system}, {"role": "user", "content": prompt_user}],
254+
temperature=0,
255+
max_tokens=200,
256+
)
257+
except Exception as exc:
258+
print(f"[SIM-USER] reply failed: {exc}")
259+
return None
260+
content = response.choices[0].message.content if response.choices else None
261+
return content.strip() if content else None
262+
263+
264+
def reply_from_facts(transcript: str, question: str, facts: list[str]) -> str:
265+
"""A legitimate question the fixture has no unused set answer for.
266+
267+
The reply may use only the fixture's facts for this turn -- never the expected output --
268+
and falls back to "use your best judgement" when the facts do not cover the question.
269+
"""
270+
if not facts:
271+
return NO_ANSWER_REPLY
272+
reply = _chat(
273+
"You play a business user talking to an analytics assistant. Answer its question briefly, "
274+
"using ONLY the facts listed below. Never invent requirements. If the facts do not answer "
275+
f"the question, reply exactly: {NO_ANSWER_REPLY}",
276+
f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}\n\n"
277+
"Facts you know:\n" + "\n".join(f"- {f}" for f in facts),
278+
)
279+
return reply or NO_ANSWER_REPLY
280+
281+
282+
def reply_restating(transcript: str, question: str) -> str:
283+
"""The assistant asked for something the conversation already established.
284+
285+
The turn is already failed; restating keeps the rest of the conversation measurable,
286+
which is what a real user would do too.
287+
"""
288+
reply = _chat(
289+
"You play a business user talking to an analytics assistant. The assistant just asked for "
290+
"something that was already said or found earlier in the conversation. Reply briefly, "
291+
"starting with 'As I said earlier,' and repeat only that information, taken from the "
292+
"conversation. Do not add anything new.",
293+
f"Conversation so far:\n{transcript}\n\nThe assistant now asks:\n{question}",
294+
)
295+
return reply or "As I said earlier in this conversation — please use what we already established."

0 commit comments

Comments
 (0)