feat(python): add evaluation framework (DeepEval + Ragas)#1453
feat(python): add evaluation framework (DeepEval + Ragas)#1453RoyiAizenstain wants to merge 6 commits into
Conversation
- Add evaluation core concepts page (docs/modules/evaluation.mdx) - Add DeepEval and Ragas adapters bridging BeeAI ChatModel - Add evaluation examples with shared dataset, agent, and custom metrics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add missing license headers - Replace print with logging in ragas adapter - Fix deprecated asyncio.get_event_loop usage - Narrow exception handling, remove unused import - Pass kwargs through in from_name factory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace print with logging in experiments and metrics - Remove duplicate LLMTestCase import in ToolUsageMetric - Remove unused Tool import in agent.py - Use collections.Counter instead of typing.Counter - Move ragas judge LLM creation outside experiment loop - Derive ragas reference tool calls from supporting_titles - Narrow exception handling in metric scoring - Use Hungarian-free variable names in deepeval experiment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new evaluation module for BeeAI agents, providing integration with DeepEval and Ragas frameworks via specialized adapters. It includes documentation, custom metrics for answer accuracy and tool usage, and example experiment scripts. Critical feedback identifies a type contract violation in the DeepEval adapter, a signature mismatch in the Ragas adapter, and flawed configuration merging for VertexAI. Further improvements are suggested to handle nested event loops during evaluation, implement robust regex-based JSON parsing, and prevent resource leaks from temporary directory creation.
| ) | ||
| ) | ||
| text = response.get_text_content() | ||
| return schema.model_validate_json(text) if schema else text # type: ignore |
There was a problem hiding this comment.
The a_generate method is defined in the DeepEvalBaseLLM interface to return a str. Returning a Pydantic model instance (schema.model_validate_json(text)) violates this contract and will cause type errors in DeepEval metrics that expect a string response to parse. Since the BeeAI framework already validates the output against the schema when response_format is used, you should return the raw JSON string.
| return schema.model_validate_json(text) if schema else text # type: ignore | |
| return text |
| if isinstance(name, str) and name.startswith("vertexai:"): | ||
| merged: dict = {"allow_prompt_caching": False} | ||
| if isinstance(options, dict): | ||
| merged.update(options) | ||
| options = merged or options |
There was a problem hiding this comment.
The logic for merging options when the provider is vertexai is flawed. If options is an instance of ChatModelParameters (rather than a dict), it will be completely overwritten by the merged dictionary, causing all original configuration to be lost. You should handle both dict and object types or use a more robust merging strategy.
| if isinstance(name, str) and name.startswith("vertexai:"): | |
| merged: dict = {"allow_prompt_caching": False} | |
| if isinstance(options, dict): | |
| merged.update(options) | |
| options = merged or options | |
| if isinstance(name, str) and name.startswith("vertexai:"): | |
| if options is None: | |
| options = {"allow_prompt_caching": False} | |
| elif isinstance(options, dict): | |
| options.setdefault("allow_prompt_caching", False) |
| def __init__(self, model_name: str): | ||
| self.model = ChatModel.from_name(model_name) |
There was a problem hiding this comment.
There is a signature mismatch between the constructor and the from_name static method. from_name accepts **kwargs and passes them to __init__, but __init__ only accepts model_name. This will raise a TypeError if any keyword arguments are passed to from_name.
| def __init__(self, model_name: str): | |
| self.model = ChatModel.from_name(model_name) | |
| def __init__(self, model_name: str, **kwargs: Any): | |
| self.model = ChatModel.from_name(model_name, **kwargs) |
| FactsSimilarityMetric(model=eval_model), | ||
| ] | ||
|
|
||
| eval_results = evaluate(test_cases=test_cases, metrics=metrics) |
There was a problem hiding this comment.
Calling deepeval.evaluate inside an already running asyncio event loop (triggered by asyncio.run(main()) on line 219) will likely raise a RuntimeError: asyncio.run() cannot be called from a running event loop. deepeval.evaluate is a synchronous function that manages its own event loop internally. You should execute the async data preparation first, then call evaluate from a synchronous context.
| clean_json = raw_text.strip() | ||
| if clean_json.startswith("```json"): | ||
| clean_json = clean_json[7:] | ||
| if clean_json.startswith("```"): | ||
| clean_json = clean_json[3:] | ||
| if clean_json.endswith("```"): | ||
| clean_json = clean_json[:-3] | ||
| clean_json = clean_json.strip() |
There was a problem hiding this comment.
The manual string slicing used to extract JSON from markdown blocks is fragile. It assumes the output starts exactly with the backticks and doesn't handle cases where the LLM might include preamble or postscript text. Using a regular expression is a more robust approach for extracting JSON content from LLM responses.
| clean_json = raw_text.strip() | |
| if clean_json.startswith("```json"): | |
| clean_json = clean_json[7:] | |
| if clean_json.startswith("```"): | |
| clean_json = clean_json[3:] | |
| if clean_json.endswith("```"): | |
| clean_json = clean_json[:-3] | |
| clean_json = clean_json.strip() | |
| import re | |
| match = re.search(r"```json\s*(.*?)\s*```", raw_text, re.DOTALL) or re.search(r"```\s*(.*?)\s*```", raw_text, re.DOTALL) | |
| clean_json = match.group(1).strip() if match else raw_text.strip() |
|
|
||
| def create_calculator_tool() -> PythonTool: | ||
| storage = LocalPythonStorage( | ||
| local_working_dir=tempfile.mkdtemp("code_interpreter_source"), |
There was a problem hiding this comment.
tempfile.mkdtemp creates a directory on the file system that is not automatically deleted. In an evaluation context where many agents might be instantiated, this can lead to an accumulation of orphaned temporary directories. It is better to use tempfile.TemporaryDirectory as a context manager or ensure explicit cleanup.
|
can you review it @Tomas2D ? |
Tomas2D
left a comment
There was a problem hiding this comment.
Thanks so much for this contribution, @RoyiAizenstain! 🐝 The Ragas adapter and the runnable end-to-end examples are a genuinely valuable addition — having reference experiments people can copy and adapt is exactly what the evaluation story needs. I left inline comments throughout; here I'll summarize the structural points that need attention before we can merge:
-
Docs need a rebase onto the new docs system. While this PR was open, the docs migrated from Mintlify to Astro Starlight (#1462), so
docs/docs.jsonanddocs/modules/no longer exist onmainand the branch is currently in merge conflict. Could you rebase and move the new page todocs/src/content/docs/modules/evaluation.mdxusing the Starlight frontmatter/components? Happy to help if the new setup is unclear. -
PR description vs. what's new.
DeepEvalLLMalready exists atpython/eval/model.py(added in #1352) — this PR moves it rather than adds it. The genuinely new pieces (the Ragas adapter and the examples) are great; it would just help reviewers if the description reflected that. -
Stranded tutorial. The
eval/→evaluation/rename moves every file exceptpython/eval/deep_eval tutorial.mdx, which would be left behind as the only file inpython/eval/— and it overlaps with the new docs page. Could you move/merge or remove it as part of this PR? -
Missing dependency declarations. The new code imports
ragas,nest_asyncio,numpy, andmultiprocess, but none of them are declared inpython/pyproject.toml(onlydeepeval = "3.3.5"exists today). Could you add them with pins — ideally behind an optionalevaluationgroup — and double-check that the DeepEval symbols used (ExactMatchMetric,ArgumentCorrectnessMetric,DEEPEVAL_PER_TASK_TIMEOUT_SECONDS_OVERRIDE) exist in the pinned version? -
Checklist. DCO sign-off,
mise check, and the test runs are still unchecked, andCloses: #<issue-number>is a leftover placeholder. A few of the new files are also missing trailing newlines (agent.py,dataset.py,dataset.json) and carryCopyright 2025headers —mise fixshould take care of most of this.
None of this diminishes the value of the work — once it's rebased and aligned with the existing harness, this will be a really nice addition. Thank you again! 🙏
|
|
||
| The dataset is a JSON file where each item contains: | ||
|
|
||
| | Field | Description | |
There was a problem hiding this comment.
Small mismatch: this table documents expected_answer, supporting_sentences, and expected_tool_calls, but python/examples/evaluation/dataset.json actually uses answer, relevant_sentences, and wiki_times. Could you align the two? (Honestly, the names documented here are the clearer ones — renaming the JSON fields to match might be the nicer fix.)
| Wraps a BeeAI `ChatModel` as a `DeepEvalBaseLLM`, enabling DeepEval metrics to call any BeeAI-supported model for LLM-as-judge evaluations. Supports structured output via Pydantic schemas. | ||
|
|
||
| ```py Python [expandable] | ||
| from evaluation.adapters import DeepEvalLLM |
There was a problem hiding this comment.
Heads-up: this presents evaluation.adapters as framework API, but python/evaluation/ is a repo-level directory that isn't part of the published beeai_framework wheel — so users who pip install beeai-framework won't be able to run this import. If we want the adapters to be public API, they should live under beeai_framework/ (perhaps gated behind an optional extra); otherwise the docs should say explicitly that this requires a repo checkout.
| merged: dict = {"allow_prompt_caching": False} | ||
| if isinstance(options, dict): | ||
| merged.update(options) | ||
| options = merged or options |
There was a problem hiding this comment.
Two thoughts here:
mergedis always non-empty at this point, somerged or optionsalways evaluates tomerged— theor optionspart is dead code.- A
vertexai:-specific workaround hardcoded inside a generic adapter (and duplicated inexamples/evaluation/agent.py) feels like the wrong layer. If prompt caching misbehaves on VertexAI, the fix probably belongs in the VertexAI backend adapter — or at minimum only in the example, with a comment explaining why.
| return None | ||
|
|
||
| # pyrefly: ignore [bad-override] | ||
| def generate(self, prompt: str, schema: BaseModel | None = None) -> str: |
There was a problem hiding this comment.
The removed # pyrefly: ignore [bad-override] comments were added during the pyrefly migration (#1347), and the overridden signatures haven't changed — so I'd expect mise check to fail without them. Could you run mise check and restore whatever it needs?
| class InstructorRagasLLM(InstructorBaseRagasLLM): | ||
| """A class that bridges Ragas with BeeAI directly (without LangChain intermediary).""" | ||
|
|
||
| def __init__(self, model_name: str): |
There was a problem hiding this comment.
For consistency with DeepEvalLLM (which wraps an existing ChatModel), consider __init__(self, model: ChatModel) plus a from_name() factory. Taking only a name string means callers can't pass a configured model (options, parameters, middleware).
Relatedly, from_name(model_name, **kwargs) below forwards **kwargs into this constructor, which accepts none — so any kwarg raises TypeError.
| self.success = score >= self.threshold | ||
| return score | ||
|
|
||
| def measure(self, test_case: LLMTestCase) -> float: |
There was a problem hiding this comment.
asyncio.run() raises RuntimeError when called from a running event loop — which is the common case under deepeval.evaluate with async metrics. Since async_mode = True already routes everything through a_measure, consider having measure() raise NotImplementedError (like the adapter does) rather than appearing to work. Same applies to FactsSimilarityMetric.measure().
| # Load dataset | ||
| _script_dir = Path(__file__).parent | ||
| _data_dir = _script_dir / "data" | ||
| dataset = Dataset.load(name="my_evaluation", backend="local/csv", root_dir=str(_data_dir)) |
There was a problem hiding this comment.
The PR description mentions "no module-load side effects", but this module loads the dataset and constructs the judge LLM at import time — so importing it fails outright if dataset.py hasn't been run yet. Moving both into main() would make the example match the description (and play nicer with tooling that imports modules to inspect them).
|
|
||
| # Suppress multiprocess resource tracker warnings on Windows - must be before other imports | ||
| # TODO: remove once multiprocess fixes ResourceTracker.__del__ on Windows | ||
| if sys.platform == "win32": |
There was a problem hiding this comment.
This is quite a lot of workaround machinery for an example users will copy as a starting point. The sys.path.insert calls above can go away with proper package-relative imports, and for the ResourceTracker monkey-patch it would be good to link the upstream multiprocess issue it works around — or drop it if it only suppresses noise at interpreter shutdown.
| ] | ||
|
|
||
|
|
||
| def create_calculator_tool() -> PythonTool: |
There was a problem hiding this comment.
The example agent unconditionally includes PythonTool, which needs a code interpreter running at http://127.0.0.1:50081 — but neither dataset question requires it, and the docs never mention starting one. Suggest dropping it from the default agent or making it opt-in via an env var, so the example works out of the box.
| ) | ||
|
|
||
| options: dict = {"allow_parallel_tool_calls": True} | ||
| if model_name.startswith("vertexai:"): |
There was a problem hiding this comment.
Same vertexai: workaround as in DeepEvalLLM.from_name — see my comment there; it would be nice to have this live in one place (ideally the VertexAI backend) rather than duplicated.
|
Hi @RoyiAizenstain — circling back on this one. Whenever you have time, the main open items from my review are:
No rush, and happy to help if any of it is unclear. Thanks again for the contribution! |
|
Thanks! I will review and fix what you asked for :) @Tomas2D |
Which issue(s) does this pull-request address?
Closes: #
Description
Adds a Python evaluation framework for systematically measuring BeeAI
agent quality on multi-hop QA, tool usage, and factual accuracy. Rather
than eyeballing outputs, users can run a fixed dataset of questions
through an agent and score the responses with pluggable metrics.
What's included:
python/evaluation/adapters/) that bridge BeeAI'sChatModelto two popular eval frameworks, so any of the 26+ BeeAIproviders can serve as the judge LLM:
DeepEvalLLM→deepeval.DeepEvalBaseLLMInstructorRagasLLM→ragas.InstructorBaseRagasLLMpython/examples/evaluation/):agent.py(aRequirementAgentfor multi-hop QA withWikipedia / OpenMeteo / PythonTool) and
dataset.jsonground truth.deepeval/experiment.py— runs the agent against the dataset andscores it with built-in DeepEval metrics plus three custom ones
(
AnswerLLMJudgeMetric,ToolUsageMetric,FactsSimilarityMetric).ragas/experiment.py— async-script runner with built-in Ragasmetrics plus a Pydantic-structured
FactsSimilarityMetric.docs/modules/evaluation.mdx) — adapter usage, env vars,invocation, output file locations.
Design highlights:
ChatModel.AGENT_CHAT_MODEL_NAME/
EVAL_CHAT_MODEL_NAMEenv vars to avoid self-bias.ollama:llama3.1:8b, matching BeeAI'sconventional default.
ragas/dataset.pyexposesbuild_dataset()callable, no pytest decorators in example scripts.No breaking changes — purely additive; existing modules untouched
apart from new docs.
Checklist
General
PythonCode quality checks
mise check(mise fixto auto-fix)Testing
mise test:unitmise test:e2eDocumentation
mise docs:fix