diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..092c452
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, develop ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - 'README*'
+ - '.gitignore'
+ pull_request:
+ branches: [ main, develop ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - 'README*'
+ - '.gitignore'
+ workflow_dispatch:
+
+jobs:
+ code-analysis:
+ name: Code Quality Analysis
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ version: "latest"
+
+ - name: Install Python
+ run: uv python install
+
+ - name: Install dependencies
+ run: uv sync --locked --all-extras --dev
+
+ - name: Check formatting
+ run: uv run ruff format --check .
+
+ - name: Check linting
+ run: uv run ruff check .
diff --git a/.gitignore b/.gitignore
index 2c54a30..8bb6629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,10 +9,6 @@ wheels/
# Virtual environments
.venv
-# Notebooks
-.ipynb_checkpoints/
-*.ipynb
-
# Data
weather_images/
@@ -22,7 +18,6 @@ weather_images/
# Common
*.DS_Store
-# Images
-*.png
-*.jpg
-*.jpeg
\ No newline at end of file
+# Data
+data/monthly_average.zarr
+data/*.nc
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..f59240a
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-added-large-files
+ - id: check-merge-conflict
+ - id: check-toml
+ - id: debug-statements
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.12.7
+ hooks:
+ - id: ruff-check
+ args: [--fix]
+ - id: ruff-format
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/LICENSE b/LICENSE
index 2ffd622..e47c543 100644
--- a/LICENSE
+++ b/LICENSE
@@ -199,4 +199,4 @@
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
- limitations under the License.
\ No newline at end of file
+ limitations under the License.
diff --git a/README.md b/README.md
index e126fad..a8ba3f4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
# EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions
+
+
+
@@ -14,14 +17,19 @@
## EarthReach
-EarthReach is a challenge from the 2025 edition dedicated to enhancing the accessibility of meteorological data visualisations produced by Earthkit, by equipping the plots module with LLM-powered alternative text generation capabilities.
+EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends [earthkit-plots](https://github.com/ecmwf/earthkit-plots) by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions.
+
+The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached.
+
+
## Installation
### Prerequisites
-- Python 3.12 or higher
-- [uv](https://docs.astral.sh/uv/) - Python package and project manager
+- [uv](https://docs.astral.sh/uv/): Python package and project manager (will automatically install Python 3.12+ if needed)
+- [Climate Data Store ](https://cds.climate.copernicus.eu/how-to-api): API key configured for accessing meteorological data
+- API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, Groq, or any OpenAI-compatible API provider)
### Setup
@@ -32,42 +40,63 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces
```
2. **Create a virtual environment and install dependencies**
```sh
- uv sync
+ uv sync --group dev
```
This command will automatically:
- Create a .venv virtual environment
- Install all project dependencies from pyproject.toml
+- Install development dependencies
3. **Activate the virtual environment**
```sh
source .venv/bin/activate # On Windows, use: .venv\Scripts\activate
```
+4. **Set up pre-commit hooks (recommended for development)**
+ ```sh
+ uv run pre-commit install
+ ```
+
You're now ready to use the project.
## Project Structure
```sh
-.
+.
├── docs/ # Project documentation
├── notebooks/ # Tutorials & experiments
├── src/
-│ ├── earth_reach_agent/ # Main package
+│ ├── earth_reach/ # Main package
│ └── tests/ # Unit and integration tests (to come)
├── vllm/ # VLLM inference server setup
├── pyproject.toml # Project dependencies and metadata
└── uv.lock # Locked dependency versions
```
-## VLLM Inference Server
+## Basic Usage
-To run this project, you will need to have an openAI-compatible LLM inference server.
+```python
+from earth_reach import EarthReachAgent
+import earthkit.plots as ekp
+import earthkit.data as ekd
-We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md).
+# Load your data with earthkit-data
+data = ekd.from_source("file", "your_data.grib")
-## Usage
+# Create a weather chart with earthkit-plots
+figure = ekp.quickplot(data, mode="overlay")
+
+# Generate description
+agent = EarthReachAgent(provider="openai")
+description = agent.generate_alt_description(figure, data)
+print(description)
+```
-The project provides a command-line interface (CLI) accessible through `earth-reach-agent` or its shorter alias `era`.
+See `notebooks/example.ipynb` for a practical usage example.
+
+## CLI Interface
+
+EarthReach includes a standalone CLI that works on image files only, producing less detailed descriptions than the full library integration.
### Available Commands
@@ -76,20 +105,66 @@ View all commands and options:
uv run era --help
```
-### Generate weather chart descriptions
-
Generate a natural language description from a weather chart image:
```sh
uv run era generate --image-path
```
-### Evaluate descriptions
-
Evaluate the accuracy of a description against a weather chart:
```sh
uv run era evaluate --image-path --description ""
```
+## VLLM Inference Server
+
+EarthReach supports any OpenAI-compatible API endpoint for self-hosted LLMs. See `vllm/` directory for a VLLM setup example.
+
+**Warning**: Self-hosting requires advanced system administration skills and significant GPU resources. Recommended only for experienced users.
+
+## Development
+
+### Development Tooling
+
+This project uses the following development tools:
+
+- **Ruff**: Fast Python linter and formatter
+- **mypy**: Static type checker for Python
+- **Pre-commit**: Git hooks for code quality checks
+- **Pytest**: Testing framework
+
+### Code Quality
+
+The project is configured with pre-commit hooks that run automatically before each commit to ensure code quality:
+
+- **Ruff linting**: Checks for common Python issues and enforces coding standards
+- **Ruff formatting**: Automatically formats code for consistency
+- **mypy type checking**: Validates type hints and catches import errors
+- **Basic checks**: Trailing whitespace, file endings, YAML/TOML syntax, merge conflicts
+
+### Running Tools Manually
+
+You can run the development tools manually:
+
+```sh
+# Run ruff linter
+uv run ruff check .
+
+# Run ruff formatter
+uv run ruff format .
+
+# Run mypy type checker
+uv run mypy .
+
+# Run pre-commit hooks on all files
+uv run pre-commit run --all-files
+```
+
+### Configuration
+
+- Ruff configuration is in `pyproject.toml` under `[tool.ruff]`
+- mypy configuration is in `pyproject.toml` under `[tool.mypy]`
+- Pre-commit configuration is in `.pre-commit-config.yaml`
+
## License
See [LICENSE](LICENSE)
diff --git a/docs/.gitkeep b/docs/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..747ffb7
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/source/_static/example_global_chart.png b/docs/source/_static/example_global_chart.png
new file mode 100644
index 0000000..2449a83
Binary files /dev/null and b/docs/source/_static/example_global_chart.png differ
diff --git a/docs/source/_static/example_regional_chart.png b/docs/source/_static/example_regional_chart.png
new file mode 100644
index 0000000..4b75626
Binary files /dev/null and b/docs/source/_static/example_regional_chart.png differ
diff --git a/docs/source/api.rst b/docs/source/api.rst
new file mode 100644
index 0000000..5112ad9
--- /dev/null
+++ b/docs/source/api.rst
@@ -0,0 +1,101 @@
+API Reference
+=============
+
+This section provides detailed documentation of the EarthReach API.
+
+Core Components
+---------------
+
+The dual-LLM framework comprises three main components:
+
+- The GeneratorAgent: creating weather chart descriptions
+- The EvaluatorAgent: evaluating the generated chart descriptions
+- The Orchestrator: driving the successive interactions between the generator and evaluator
+
+GeneratorAgent
+~~~~~~~~~~~~~~
+
+.. autoclass:: earth_reach.core.generator.GeneratorAgent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: earth_reach.core.generator.GeneratorOutput
+ :members:
+ :show-inheritance:
+
+EvaluatorAgent
+~~~~~~~~~~~~~~
+
+.. autoclass:: earth_reach.core.evaluator.EvaluatorAgent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: earth_reach.core.evaluator.CriterionEvaluatorOutput
+ :members:
+ :show-inheritance:
+
+Orchestrator
+~~~~~~~~~~~~
+
+.. autoclass:: earth_reach.core.orchestrator.Orchestrator
+ :members:
+ :show-inheritance:
+
+Data Extractors
+---------------
+
+Data extractors analyze GRIB file metadata to extract relevant meteorological information, which is then used to enhance prompts for both the generator and evaluator agents.
+
+Base Extractor
+~~~~~~~~~~~~~~
+
+.. automodule:: earth_reach.core.extractors.base_extractor
+ :members:
+ :show-inheritance:
+
+Pressure Extractor
+~~~~~~~~~~~~~~~~~~
+
+.. automodule:: earth_reach.core.extractors.pressure_extractor
+ :members:
+ :show-inheritance:
+
+Configuration
+-------------
+
+LLM Interface
+~~~~~~~~~~~~~
+
+.. autoclass:: earth_reach.core.llm.LLMInterface
+ :members:
+ :show-inheritance:
+
+Evaluation Criteria
+~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: earth_reach.config.criteria
+ :members:
+ :show-inheritance:
+
+Logging Configuration
+~~~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: earth_reach.config.logging
+ :members:
+ :show-inheritance:
+
+Command Line Interface
+----------------------
+
+The CLI provides convenient access to EarthReach's core functionality through command-line commands. However, as it operates solely on image files without access to underlying GRIB metadata, the CLI generates less detailed descriptions compared to the full *earthkit-plots* library integration.
+
+.. automodule:: earth_reach.cli
+ :members:
+ :show-inheritance:
+
+Utilities
+---------
+
+.. automodule:: earth_reach.core.utils
+ :members:
+ :show-inheritance:
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..d51f7f9
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,36 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath("../../src/earth_reach"))
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "EarthReach"
+copyright = "2025, Romain Bazin"
+author = "Romain Bazin"
+release = "1"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.viewcode",
+ "sphinx.ext.napoleon",
+]
+
+templates_path = ["_templates"]
+exclude_patterns = []
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "sphinx_rtd_theme"
+html_static_path = ["_static"]
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..453d526
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,48 @@
+EarthReach documentation
+========================
+
+|Python 3.12| |License: Apache 2.0|
+
+.. |Python 3.12| image:: https://img.shields.io/badge/python-3.12-blue.svg
+ :target: https://www.python.org/downloads/release/python-3120/
+
+.. |License: Apache 2.0| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg
+ :target: https://opensource.org/licenses/apache-2-0
+
+EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends **earthkit-plots** by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions.
+
+The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached.
+
+Supported Chart Types
+---------------------
+
+EarthReach supports two primary types of weather charts, both requiring temperature and pressure data overlays:
+
+**Global Charts**
+
+.. image:: _static/example_global_chart.png
+ :alt: Example global weather chart
+ :align: center
+ :width: 600px
+
+Global-scale meteorological visualizations showing worldwide weather patterns, typically displaying temperature and pressure fields across continental and oceanic regions.
+
+**Regional Charts**
+
+.. image:: _static/example_regional_chart.png
+ :alt: Example regional weather chart
+ :align: center
+ :width: 600px
+
+Regional-scale meteorological visualizations focusing on specific geographical areas, providing temperature and pressure analysis for localized weather systems.
+
+.. note::
+ The current system is optimized for global charts. But as regional charts are more commonly encountered in practice, future development should focus on improving regional chart analysis quality.
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+ installation
+ usage
+ api
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
new file mode 100644
index 0000000..4a287d0
--- /dev/null
+++ b/docs/source/installation.rst
@@ -0,0 +1,76 @@
+Installation
+============
+
+Prerequisites
+-------------
+
+- `uv `_: Python package and project manager (will automatically install Python 3.12+ if needed)
+- `Climate Data Store `_ API key configured for accessing meteorological data
+- API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, or Groq)
+
+Dependencies
+------------
+
+1. **Clone the repository**
+
+ .. code-block:: bash
+
+ git clone https://github.com/ECMWFCode4Earth/EarthReach.git
+ cd EarthReach
+
+2. **Create a virtual environment and install dependencies**
+
+ .. code-block:: bash
+
+ uv sync
+
+ This command will automatically:
+
+ - Create a .venv virtual environment
+ - Install all project dependencies from pyproject.toml
+
+3. **Activate the virtual environment**
+
+ .. code-block:: bash
+
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
+
+LLM Provider Configuration
+--------------------------
+
+EarthReach requires an external LLM provider to function. The system supports multiple providers in a bring-your-own-key fashion. Set the appropriate environment variable for your chosen provider:
+
+**OpenAI**
+
+.. code-block:: bash
+
+ export OPENAI_API_KEY="your-openai-api-key"
+
+**Google Gemini**
+
+.. code-block:: bash
+
+ export GEMINI_API_KEY="your-gemini-api-key"
+
+**Anthropic Claude**
+
+.. code-block:: bash
+
+ export ANTHROPIC_API_KEY="your-claude-api-key"
+
+**Groq**
+
+.. code-block:: bash
+
+ export GROQ_API_KEY="your-groq-api-key"
+
+.. note::
+ You only need to configure one provider.
+
+Self-hosted LLM Server (Advanced)
+----------------------------------
+
+For users who prefer to host their own LLM inference server, EarthReach can work with any OpenAI-compatible API endpoint. An example setup using VLLM on Rocky Linux/RHEL is available in the ``vllm/`` directory of this repository.
+
+.. warning::
+ Setting up a self-hosted inference server requires advanced system administration knowledge and significant computational resources. This approach is recommended only for users with experience in server deployment and GPU management.
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
new file mode 100644
index 0000000..cb79d36
--- /dev/null
+++ b/docs/source/usage.rst
@@ -0,0 +1,137 @@
+Usage
+=====
+
+Library Integration
+-------------------
+
+The primary way to use EarthReach is through the ``EarthReachAgent`` class, designed for integration with earthkit-plots and other Python applications.
+
+Basic Usage
+~~~~~~~~~~~
+
+.. code-block:: python
+
+ from earth_reach import EarthReachAgent
+ import earthkit.plots as ekp
+ import earthkit.data as ekd
+
+ # Load your data with earthkit-data
+ data = ekd.from_source("file", "your_data.grib")
+
+ # Create a weather chart with earthkit-plots
+ figure = ekp.quickplot(data, mode="overlay")
+
+ # Generate description
+ agent = EarthReachAgent(provider="openai")
+ description = agent.generate_alt_description(figure, data)
+ print(description)
+
+See `notebooks/example.ipynb` for a practical usage example.
+
+Input Requirements
+~~~~~~~~~~~~~~~~~~
+
+The ``generate_alt_description`` method requires:
+
+- **figure**: An ``earthkit.plots.Figure`` object containing the weather chart
+- **data**: An ``earthkit.data.FieldList`` containing GRIB meteorological data
+
+**Required Variables**: The data must contain both:
+
+- ``2t``: 2-meter temperature fields
+- ``msl``: Mean sea level pressure fields
+
+Configuration Options
+~~~~~~~~~~~~~~~~~~~~~
+
+Customize the agent behavior with initialization parameters:
+
+.. code-block:: python
+
+ agent = EarthReachAgent(
+ provider="gemini", # Required: LLM provider
+ model_name="gemini-2.5-pro", # Optional: specific model (uses provider default)
+ max_iterations=5, # Maximum evaluation iterations (default: 3)
+ criteria_threshold=3 # Minimum quality score to pass (default: 4)
+ )
+
+**Supported Providers**:
+
+- ``"openai"``: OpenAI models (GPT-3.5, GPT-4, etc.)
+- ``"gemini"``: Google Gemini models
+- ``"anthropic"``: Anthropic Claude models
+- ``"groq"``: Groq models
+
+.. note::
+ Ensure you have configured the API key for your chosen provider as an environment variable (e.g., ``OPENAI_API_KEY``, ``GEMINI_API_KEY``, etc.). See the :doc:`installation` section for details.
+
+Command Line Interface
+----------------------
+
+EarthReach also provides a command-line interface for convenient standalone usage. However, as it operates solely on image files without access to underlying GRIB metadata, the CLI generates less detailed descriptions compared to the full earthkit-plots library integration.
+
+Getting Started
+---------------
+
+The CLI is accessible through the ``era`` command (short for earth-reach-agent). View all available commands:
+
+.. code-block:: bash
+
+ uv run era --help
+
+Generate Descriptions
+---------------------
+
+Generate a natural language description from a weather chart image:
+
+.. code-block:: bash
+
+ uv run era generate --image-path
+
+**Options:**
+
+- ``--image-path``: Path to the weather chart image file
+- ``--simple``: Use simple mode (generator only, no evaluation loop)
+- ``--prompt-path``: Path to a custom prompt file (optional)
+
+**Example:**
+
+.. code-block:: bash
+
+ uv run era generate --image-path ./charts/temperature_map.png
+
+Evaluate Descriptions
+---------------------
+
+Evaluate the quality of a description against a weather chart:
+
+.. code-block:: bash
+
+ uv run era evaluate --image-path --description ""
+
+**Options:**
+
+- ``--image-path``: Path to the weather chart image file
+- ``--description``: The description text to evaluate
+- ``--prompt-path``: Path to a custom prompt file (optional)
+
+**Example:**
+
+.. code-block:: bash
+
+ uv run era evaluate --image-path ./charts/temperature_map.png --description "Temperature ranges from 10C to 25C across the region"
+
+Output Format
+-------------
+
+The CLI outputs structured information including:
+
+- Generated descriptions with metadata
+- Evaluation scores across multiple criteria (coherence, fluency, consistency, relevance)
+- Processing time and iteration counts
+- Quality metrics and feedback
+
+Environment Variables
+---------------------
+
+Ensure you have configured the appropriate LLM provider as described in the :doc:`installation` section.
diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py
deleted file mode 100644
index 42ba1a0..0000000
--- a/notebooks/earthkit_plots_exploration.py
+++ /dev/null
@@ -1,582 +0,0 @@
-# ---
-# jupyter:
-# jupytext:
-# cell_metadata_filter: -all
-# formats: ipynb,py:percent
-# text_representation:
-# extension: .py
-# format_name: percent
-# format_version: '1.3'
-# jupytext_version: 1.17.1
-# kernelspec:
-# display_name: .venv
-# language: python
-# name: python3
-# ---
-
-# %% [markdown]
-# # Earthkit Plots Exploration
-
-# %% [markdown]
-# ## Configuration & Imports
-
-# %%
-import base64
-import os
-import warnings
-from io import BytesIO
-from pathlib import Path
-import pandas as pd
-
-from earthkit.data import cache, config
-import earthkit as ek
-import openai
-from PIL import Image
-from typing import List, Dict
-from dotenv import load_dotenv
-from IPython.display import Markdown, display
-
-load_dotenv(Path().cwd().parent / ".env")
-
-warnings.filterwarnings("ignore")
-
-config.set("cache-policy", "user")
-print("cache:", cache.directory())
-
-
-# %% [markdown]
-# ## Definitions
-
-# %%
-def display_markdown(text: str) -> None:
- """
- Display a string as Markdown in a Jupyter notebook.
-
- Args:
- text (str): The text to display as Markdown.
- """
- display(Markdown(text))
-
-
-def img_to_base64(image_path: str | None = None, img = None) -> str:
- """
- Convert an image to a base64 string.
-
- Args:
- image_path (str): The path to the image file. Either this or img must be provided.
- img (PIL.Image): The image object. Either this or image_path must be provided.
-
- Returns:
- str: The base64 string representation of the image.
- """
- if image_path is None and img is None:
- raise ValueError("Either image_path or img must be provided.")
-
- if img is not None:
- bytes_io = BytesIO()
- img.save(bytes_io, format="PNG")
- return base64.b64encode(bytes_io.getvalue()).decode("utf-8")
-
- with open(image_path, "rb") as img_file: # type: ignore
- return base64.b64encode(img_file.read()).decode("utf-8")
-
-
-# %%
-assert os.environ.get("GROQ_API_KEY"), (
- "GROQ_API_KEY not set. Please set it in your environment variables."
-)
-client = openai.OpenAI(
- base_url="https://api.groq.com/openai/v1",
- api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider
-)
-
-MODEL_NAME = (
- "meta-llama/llama-4-maverick-17b-128e-instruct" # vision-enabled chat model
-)
-
-
-def call_llm_api(user_prompt: str, image) -> str:
- """Call the LLM generation API"""
- base64_image = img_to_base64(img=image)
- try:
- response = client.chat.completions.create(
- model=MODEL_NAME,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "text", "text": user_prompt},
- {
- "type": "image_url",
- "image_url": {
- "url": f"data:image/jpeg;base64,{base64_image}",
- },
- },
- ],
- }
- ],
- )
- description = response.choices[0].message.content
-
- if not description:
- raise ValueError("The description generated is empty")
-
- return description
-
- except Exception as e:
- print("Encountered unexpected error when calling the api: {e}")
- raise e
-
-
-# %% [markdown]
-# ## Loading Data
-
-# %%
-# Example, quickly accessible data
-data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib")
-data.ls()
-
-# %%
-# Copernicus datastore, requires API key set
-dataset = "reanalysis-era5-single-levels"
-
-
-def get_weather_data(date: str):
- date_list = date.split("/")
-
- request = {
- "product_type": ["reanalysis"],
- "variable": ["2m_temperature", "mean_sea_level_pressure"],
- "day": [date_list[0]],
- "month": [date_list[1]],
- "year": [date_list[2]],
- "time": ["12:00"],
- "data_format": "grib",
- "download_format": "unarchived",
- }
-
- data = ek.data.from_source("cds", dataset, request)
-
- return data
-
-
-event_date = "12/03/2011"
-data = get_weather_data(event_date)
-
-data.ls()
-
-# %% [markdown]
-# ## Generating Plots from Interesting Weather Events
-
-# %%
-# NOTE: those dates are probably most relevant for temperature but are they also for pressure ?
-# Maybe we should consider events that have two interesting events ?
-# Possible configurations: interesting event present / not present, parameter shown / not shown
-weather_events = {
- "average_days": {"global": ["12/03/2011", "20/07/2010", "05/11/2003"]},
- "cold_days": {"global": ["13/03/1976", "21/07/1943", "05/11/1975"]},
- "hot_days": {"global": ["14/03/2024", "22/07/2024", "08/11/2023"]},
- "heatwave": {"France": ["30/04/2025"], "Arctic": ["17/04/2015"]},
- "cold_wave": {"United States": ["31/01/2019"], "Europe": ["27/02/2018"]},
-}
-
-
-# %%
-def save_weather_event_images(weather_events, output_dir="weather_images"):
- """
- Save weather event images and create a CSV mapping file.
-
- Args:
- weather_events: Dictionary of weather events
- output_dir: Directory to save the images and CSV file
- """
- os.makedirs(output_dir, exist_ok=True)
-
- figure_metadata = []
- figure_id = 1
- try:
- for event_type, domains in weather_events.items():
- for domain_type, dates in domains.items():
- for date_str in dates:
- data = get_weather_data(date_str)
-
- sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface")
-
- kwargs: Dict[str, List] = (
- {"domain": [domain_type]}
- if domain_type.lower() != "global"
- else {}
- )
-
- buffer = BytesIO()
- figure = ek.plots.quickplot(
- sub_data,
- units=["celsius", "hPa"],
- mode="overlay",
- **kwargs, # type: ignore
- )
- figure.save(buffer, format="png")
-
- image_filename = f"figure_{figure_id}.png"
- image_path = os.path.join(output_dir, image_filename)
- buffer.seek(0)
- img = Image.open(buffer)
- img.save(image_path)
-
- figure_metadata.append(
- {
- "figure_id": figure_id,
- "event_type": event_type,
- "domain_type": domain_type,
- "date": date_str,
- }
- )
-
- figure_id += 1
- except:
- pass
-
- metadata_df = pd.DataFrame(figure_metadata)
- csv_path = os.path.join(output_dir, "figure_metadata.csv")
- metadata_df.to_csv(csv_path, index=False)
-
- print(f"Saved {figure_id - 1} figures and metadata to {output_dir}")
- return metadata_df
-
-
-metadata_df = save_weather_event_images(weather_events)
-
-# %% [markdown]
-# ## Exploring GRIB data
-
-# %% [markdown]
-# When loaded through the `.from_source()` method, a GRIB file will yield a `GribFielList` object. Documentation available [here](https://earthkit-data.readthedocs.io/en/latest/guide/data_format/grib.html)
-#
-# Some information about fields and GRIB data:
-# - The fields can be iterated through with for loops
-# - A field represents a single meteorological phenomenon (like temperature), measured at a certain time, at a certain height, from a certain center
-
-# %%
-len(data) # Number of fields
-
-# %%
-data.head() # List first 5 fields and "some" metadata for each field
-
-# %%
-data.ls() # List all fields and *some* metadata for each field
-
-# %%
-data.describe() # Describe the fields
-
-# %%
-data[0].dump() # Access different metadata namespaces
-
-# %%
-data[0].metadata(namespaces="statistics")
-
-# %%
-data.indices() # List of the metadata and the values they can take
-
-# %% [markdown]
-# ## Earthkit-plot Quickplot API
-
-# %%
-data.head()
-
-# %%
-# Sub-select the data
-sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface")
-sub_data.head()
-
-# %%
-buffer = BytesIO()
-figure = ek.plots.quickplot(sub_data, domain=["France"], mode="overlay")
-
-figure.save(buffer, format="png")
-
-buffer.seek(0)
-
-img = Image.open(buffer)
-
-# %%
-figure.title() # TODO: extract the string title
-
-# %% [markdown]
-# Observations:
-# - Geographic information is represented almost purely visually (there are latitudes and longitudes information on the axes but they are hardly interpretable), so a model should recognize the domains, or have access to them in the forms of variable valuess, default being the whole world
-# - Pressure values are only represented as visual isobars, which might be the information the hardest to understand visually. A better approach could be to use the data values.
-# - Temperature values are visually represented with a gradient of colors, and a clear legend. I think it would be quite easily interpreted by a model.
-# - Title provides important context about the information represented and metadata such as the date. It should probably be transmitted to an end user. It can be accessed visually but also through the figure attributes.
-# - In the end, the goal of interpreting this type of images could be phrased as "understanding the spatial distribution of meteorological variables."
-
-# %% [markdown]
-# ## Trying a Simple VLLM Summary Generation
-
-# %%
-task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists
-
-Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists.
-
-"""
-
-method_prompt = """## Method
-
-To achieve this task, you will work in two three steps.
-1. Understanding the information of the visualization
-2. Planning your description
-3. Writing your description
-
-First,reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey.
-
-Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization.
-
-So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously
-
-"""
-
-requirements_prompt = """## Requirements
-
-Here are requirements that you will have to follow rigorously when writing your description.
-
-### 1. Structural Organization
-- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed.
-- Organize your description hierarchically from most to least scientifically significant features.
-- Structure your description in clearly defined sections with standardized headings.
-
-### 2. Scientific Content Requirements
-- Use precise meteorological terminology consistent with scientific publications.
-- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature).
-- Describe patterns and gradients rather than individual data points.
-- Explicitly state the range of values shown for each variable.
-- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients).
-- Include geographic context using cardinal directions and recognized regional references.
-
-### 3. Spatial Relationship Guidelines
-- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.).
-- Reference latitude and longitude coordinates when describing specific features.
-- Describe gradients and transitions using directional language (e.g., "increasing from south to north").
-- Use recognized geographic features (countries, seas, mountain ranges) as reference points.
-
-### 4. Pattern Description Protocol
-- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts).
-- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems).
-- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution."
-- Note any anomalies or unusual features in the data pattern.
-
-### 5. Technical Elements
-- Describe the scale and units prominently displayed in the visualization.
-- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure).
-- Note the resolution and any apparent limitations of the data.
-
-### 6. Scientific Context
-- Include relevant seasonal context (e.g., winter conditions for December visualization).
-- Provide brief meteorological context for the significance of the date, if applicable.
-- Relate the patterns shown to typical or expected meteorological conditions for the region and season.
-
-### 7. Accessibility-Specific Considerations
-- Use clear, unambiguous language that doesn't rely on visual references.
-- Avoid phrases like "as you can see" or "looking at the map."
-- Use directional and relative terms that don't require visual understanding.
-- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns.
-
-### 8. Language Style
-- Maintain a formal, scientific tone throughout.
-- Use precise, concise language without unnecessary elaboration.
-- Be objective in descriptions, clearly differentiating between observed data and interpretations.
-
-"""
-
-format_prompt = """"""
-
-examples_prompt = """"""
-
-user_prompt = (
- task_prompt + method_prompt + requirements_prompt + format_prompt + examples_prompt
-)
-
-# %%
-description = call_llm_api(user_prompt, img)
-
-display_markdown(description)
-
-# %% [markdown]
-# Observations (from a very simple prompt):
-# - Good structured and objective documentation of visual elements
-# - Can accurately identify technical components of scientific visualizations
-# - Remains cautious about making interpretive claims beyond what's explicitly shown
-# - Prioritizes factual accuracy over speculative analysis
-#
-# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start.
-
-# %%
-enhanced_prompt = """# Enhanced Weather Chart Alt-Text Generation System
-
-## ROLE AND CONTEXT SETTING
-
-You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible.
-
-**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues.
-
-**Critical Context**: Your descriptions will be used for:
-- Research analysis and data interpretation
-- Scientific paper writing and peer review
-- Teaching and educational materials
-- Operational weather forecasting decisions
-
-## TASK DECOMPOSITION
-
-### Step 1: Data Extraction and Analysis (200-500 words)
-**Objective**: Systematically catalog all quantitative information and meteorological features
-
-**Actions**:
-1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.)
-2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global")
-3. List all meteorological systems visible (highs, lows, fronts, convergence zones)
-4. Document the time period, date, and any temporal information
-5. Record the data source, resolution, and any technical specifications shown
-
-**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"?
-
-### Step 2: Pattern Recognition and Spatial Analysis (200-500 words)
-**Objective**: Identify the dominant meteorological patterns and their spatial relationships
-
-**Actions**:
-1. Determine the primary weather systems and rank them by meteorological significance
-2. Map the directional flow of gradients (temperature, pressure) using cardinal directions
-3. Identify spatial correlations between different variables
-4. Note any unusual or anomalous features that deviate from expected patterns
-5. Assess the seasonal/temporal context and typical vs. atypical conditions
-
-**Success Check**: Can you explain how each major system influences the others spatially?
-
-### Step 3: Description Planning and Structure Design (100-150 words)
-**Objective**: Create a hierarchical outline that prioritizes information by scientific importance
-
-**Actions**:
-1. Rank meteorological features by their significance for understanding weather patterns
-2. Plan the logical flow from overview to specific details
-3. Determine which quantitative values are essential vs. supplementary
-4. Identify the most effective way to convey spatial relationships
-5. Plan transitions between sections to maintain scientific coherence
-
-**Success Check**: Would a meteorologist reading your outline understand the complete weather situation?
-
-### Step 4: Description Writing with Verification (300-500 words)
-**Objective**: Produce the final description following all specifications
-
-**Actions**:
-1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables
-2. Develop the main body with 4-6 sentences, covering:
- - Dominant systems and their characteristics
- - Variable distributions (temperature, pressure, wind)
- - Spatial relationships and correlations
- - Notable features or anomalies
-3. Ensure all quantitative values include specific ranges and units
-4. Use precise meteorological terminology and avoid visual-dependent language
-
-**Success Check**: Does your description pass all items in the quality verification checklist?
-
-## CONCRETE CONSTRAINTS AND SPECIFICATIONS
-
-### Length and Structure Requirements
-- **Total description length**: 300-500 words maximum
-- **Overview section**: Exactly 2-3 sentences
-- **Main body**: 4-6 sentences
-
-### Quantitative Requirements
-- **Include exact ranges**: Every variable must have minimum and maximum values with units
-- **Spatial precision**: Use cardinal directions and geographic references
-- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable)
-- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km)
-
-### Language Specifications
-- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience)
-- **Sentence structure**: Maximum 25 words per sentence
-- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below)
-- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..."
-
-## ERROR PREVENTION GUIDELINES
-
-### Common Pitfalls to Avoid
-1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values
-2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously"
-3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references
-4. **Unit omissions**: Every quantitative statement must include appropriate units
-5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly
-
-## SUCCESS CRITERIA
-
-### Objective Standards
-1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description
-2. **Quantitative Accuracy**: All numerical values within ±5% of actual data
-3. **Accessibility Compliance**: 100% of visual information conveyed through text
-4. **Scientific Utility**: Description enables the same analytical conclusions as the visual
-5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards
-
-## EXAMPLES AND REFERENCE PATTERNS
-
-Not yet available, but will be provided in the future.
-
-## OUTPUT FORMAT REQUIREMENTS
-
-### XML Tag Structure
-**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing.
-
-**Required XML Tags:**
-- `...` - Wrap the entire content of Step 1 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 2 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 3 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 4 (excluding the markdown header)
-- `...` - Wrap only the final consolidated description (without headers or meta-commentary)
-
-### Formatting Rules
-1. **Tag Placement**: Place opening and closing tags on their own lines
-2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X)
-3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements
-4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief
-
-### Example Structure:
-```
-## Step 1: Data Extraction and Analysis
-
-The chart displays 2-meter temperature and mean sea level pressure...
-[rest of step 1 content]
-
-
-## Step 2: Pattern Recognition and Spatial Analysis
-
-The dominant feature is a high-pressure system...
-[rest of step 2 content]
-
-
-## Step 3: Description Planning and Structure Design
-
-To structure the description, we will start with an overview...
-[rest of step 3 content]
-
-
-## Step 4: Description Writing with Verification
-
-### Overview
-This weather chart displays...
-### Main Body
-A high-pressure system is centered...
-[rest of step 4 content]
-
-
-
-This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center.
-
-```
-
-**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures.
-
-**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists.
-"""
-
-# display_markdown(enhanced_prompt)
-
-# %%
-description = call_llm_api(enhanced_prompt, img)
-
-display_markdown(description)
diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb
new file mode 100644
index 0000000..74a38e6
--- /dev/null
+++ b/notebooks/example.ipynb
@@ -0,0 +1,114 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "cb4f99c1",
+ "metadata": {},
+ "source": [
+ "# EarthReach - Weather Chart Description Generation Example "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7918fff6",
+ "metadata": {},
+ "source": [
+ "### Introduction\n",
+ "\n",
+ "EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends **earthkit-plots** by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions.\n",
+ "\n",
+ "The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached.\n",
+ "\n",
+ "This notebook demonstrates the workflow for using the EarthReachAgent API to generate descriptions of weather charts.\n",
+ "\n",
+ "### Prerequisites\n",
+ "\n",
+ "Before running this notebook, ensure you have:\n",
+ "\n",
+ "- **Python 3.12+** with all project dependencies installed\n",
+ "- **[Climate Data Store](https://cds.climate.copernicus.eu/how-to-api) API key** configured for accessing meteorological data\n",
+ "- **LLM provider API key** set as an environment variable (supports OpenAI, Anthropic, or compatible endpoints)\n",
+ "\n",
+ "For more detailed setup instructions, refer to the [installation documentation](../docs/source/installation.rst)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8791358b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import earthkit.plots as ekp\n",
+ "import earthkit.data as ekd\n",
+ "from earth_reach import EarthReachAgent\n",
+ "from textwrap import fill\n",
+ "import warnings\n",
+ "import logging\n",
+ "\n",
+ "logging.getLogger('htpx').setLevel(logging.WARNING)\n",
+ "logging.getLogger('google_genai').setLevel(logging.WARNING)\n",
+ "\n",
+ "warnings.filterwarnings('ignore')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d22b505a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Load your data with earthkit-data\n",
+ "request = {\n",
+ " \"product_type\": [\"reanalysis\"],\n",
+ " \"variable\": [\"2m_temperature\", \"mean_sea_level_pressure\"],\n",
+ " \"day\": ['30'],\n",
+ " \"month\": ['04'],\n",
+ " \"year\": ['2025'],\n",
+ " \"time\": ['12:00'],\n",
+ " \"data_format\": \"grib\",\n",
+ " \"download_format\": \"unarchived\",\n",
+ "}\n",
+ "dataset = \"reanalysis-era5-single-levels\"\n",
+ "data = ekd.from_source(\"cds\", dataset, request)\n",
+ "\n",
+ "# Preprocess the data as you like, but make sure these two variables are present in the dataset\n",
+ "sub_data = data.sel(param=[\"2t\", \"msl\"], typeOfLevel=\"surface\")\n",
+ "\n",
+ "# Create a weather chart with earthkit-plots\n",
+ "figure = ekp.quickplot(sub_data, domain='France', units=['celsius', 'hPa'], mode=\"overlay\")\n",
+ "\n",
+ "# Generate description\n",
+ "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pro') \n",
+ "description = agent.generate_alt_description(figure, sub_data)\n",
+ "\n",
+ "# Visualize description\n",
+ "print('\\nGenerated Description:')\n",
+ "print('-' * 22)\n",
+ "print(fill(description, 88))"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "EarthReach",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index ffdf422..b0f52c7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
-name = "earth-reach-agent"
+name = "earth-reach"
version = "0.1.0"
description = "EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions"
readme = "README.md"
@@ -12,24 +12,96 @@ dependencies = [
"earthkit>=0.10.3",
"earthkit-data>=0.13.8",
"earthkit-plots>=0.3.1",
+ "scipy>=1.15.2",
"openai>=1.77.0",
"python-dotenv>=1.1.0",
"hatchling>=1.27.0",
- "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue
+ "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue
+ "zarr>=3.1.1",
]
[dependency-groups]
dev = [
"jupytext>=1.17.1",
"ipykernel>=6.29.5",
+ "ruff>=0.12.7",
+ "pytest>=8.3.5",
+ "pre-commit>=4.0.1",
+ "mypy>=1.8.0",
+ "scipy-stubs>=1.16.1.0",
+ "sphinx>=8.2.3",
+ "sphinx-rtd-theme>=3.0.2",
+]
+
+[project.optional-dependencies]
+gemini = [
+ "google-genai>=1.27.0",
+]
+claude = [
+ "anthropic>=0.60.0",
+]
+all-models = [
+ "google-genai>=1.27.0",
+ "anthropic>=0.60.0"
]
[project.scripts]
-earth-reach-agent = "earth_reach_agent.cli:cli"
-era = "earth_reach_agent.cli:cli"
+earth-reach-agent = "earth_reach.cli:cli"
+era = "earth_reach.cli:cli"
[tool.hatch.build.targets.wheel]
-packages = ["src/earth_reach_agent"]
+packages = ["src/earth_reach"]
[tool.hatch.metadata]
allow-direct-references = true
+
+[tool.ruff]
+line-length = 88
+target-version = "py312"
+exclude = ["notebooks/"]
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+ "COM", # trailing commas
+ "Q", # quotes
+ "SIM", # simplify
+ "RUF", # Ruff-specific rules
+ "PIE", # flake8-pie
+ "RET", # flake8-return
+ "TCH", # type-checking
+]
+ignore = [
+ "E501", # line too long, handled by formatter
+ "COM812", # trailing comma conflicts with formatter
+]
+fixable = ["ALL"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+
+[tool.ruff.lint.isort]
+known-first-party = ["earth_reach"]
+force-single-line = false
+lines-between-types = 1
+split-on-trailing-comma = true
+
+[tool.mypy]
+python_version = "3.12"
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
+files = ["src/earth_reach"]
+
+# Auto type checking for untyped packages
+[[tool.mypy.overrides]]
+module = ["earthkit.*", "fire.*"]
+follow_untyped_imports = true
+warn_return_any = true
diff --git a/src/earth_reach/__init__.py b/src/earth_reach/__init__.py
new file mode 100644
index 0000000..b8c4beb
--- /dev/null
+++ b/src/earth_reach/__init__.py
@@ -0,0 +1,22 @@
+"""
+Main package.
+
+Dual-LLM framework for generating natural language descriptions of meteorological
+data visualizations, making weather charts accessible to blind and low-vision scientists.
+"""
+
+from earth_reach.core.evaluator import EvaluatorAgent
+from earth_reach.core.generator import GeneratorAgent
+from earth_reach.core.llm import GeminiLLM, GroqLLM, OpenAILLM
+from earth_reach.core.orchestrator import Orchestrator
+from earth_reach.main import EarthReachAgent
+
+__all__ = [
+ "EarthReachAgent",
+ "EvaluatorAgent",
+ "GeminiLLM",
+ "GeneratorAgent",
+ "GroqLLM",
+ "OpenAILLM",
+ "Orchestrator",
+]
diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach/cli.py
similarity index 75%
rename from src/earth_reach_agent/cli.py
rename to src/earth_reach/cli.py
index 25ffdec..acb8e3c 100644
--- a/src/earth_reach_agent/cli.py
+++ b/src/earth_reach/cli.py
@@ -3,30 +3,24 @@
CLI entrypoint for generating weather chart descriptions.
"""
-import logging
import os
import sys
+
from pathlib import Path
-from typing import List
import fire
-from dotenv import load_dotenv
-from PIL import Image
-from earth_reach_agent.config.criteria import QualityCriteria
-from earth_reach_agent.core.evaluator import EvaluatorAgent
-from earth_reach_agent.core.generator import GeneratorAgent
-from earth_reach_agent.core.llm import create_llm
-from earth_reach_agent.core.orchestrator import Orchestrator
-from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt
+from PIL import Image
-logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
-)
-logger = logging.getLogger(__name__)
+from earth_reach.config.criteria import QualityCriteria
+from earth_reach.config.logging import get_logger
+from earth_reach.core.evaluator import EvaluatorAgent
+from earth_reach.core.generator import GeneratorAgent
+from earth_reach.core.llm import create_llm
+from earth_reach.core.orchestrator import Orchestrator
+from earth_reach.core.prompts.generator import get_default_generator_user_prompt
-load_dotenv()
+logger = get_logger(__name__)
def load_prompt_from_file(file_path: str) -> str:
@@ -48,13 +42,13 @@ def load_prompt_from_file(file_path: str) -> str:
raise FileNotFoundError(f"Prompt file not found: {file_path}")
try:
- with open(file_path, "r", encoding="utf-8") as f:
+ with open(file_path, encoding="utf-8") as f:
content = f.read().strip()
if not content:
raise ValueError(f"Prompt file is empty: {file_path}")
return content
except Exception as e:
- raise IOError(f"Failed to read prompt file '{file_path}': {e}")
+ raise OSError(f"Failed to read prompt file '{file_path}': {e}") from e
def resolve_prompt(
@@ -78,7 +72,7 @@ def resolve_prompt(
"""
if direct_prompt is not None and file_path is not None:
raise ValueError(
- "Cannot specify both prompt file and prompt text. Please use only one."
+ "Cannot specify both prompt file and prompt text. Please use only one.",
)
if direct_prompt is not None:
@@ -113,7 +107,7 @@ def resolve_description(
if description and description_file_path:
raise ValueError(
"Cannot provide both description text and description file path. "
- "Please provide only one."
+ "Please provide only one.",
)
if description:
@@ -122,19 +116,19 @@ def resolve_description(
if description_file_path:
if not os.path.exists(description_file_path):
raise FileNotFoundError(
- f"Description file not found: {description_file_path}"
+ f"Description file not found: {description_file_path}",
)
try:
- with open(description_file_path, "r", encoding="utf-8") as file:
+ with open(description_file_path, encoding="utf-8") as file:
return file.read().strip()
except Exception as e:
- raise ValueError(f"Error reading description file: {e}")
+ raise ValueError(f"Error reading description file: {e}") from e
return None
-def get_valid_criteria() -> List[str]:
+def get_valid_criteria() -> list[str]:
"""
Get list of valid evaluation criteria.
@@ -170,7 +164,7 @@ def validate_image_path(image_path: str) -> Path:
if path.suffix not in valid_extensions:
raise ValueError(
f"Unsupported image format: {path.suffix}. "
- f"Supported formats: {', '.join(sorted(valid_extensions))}"
+ f"Supported formats: {', '.join(sorted(valid_extensions))}",
)
return path
@@ -216,48 +210,55 @@ def generate(
ValueError: If arguments are invalid or conflicting
RuntimeError: If description generation fails
"""
+ logger.info("Starting description generation...")
try:
- if verbose:
- logger.info(f"Validating image: {image_path}")
validated_image_path = validate_image_path(image_path)
image = Image.open(validated_image_path)
- if verbose:
- logger.info("Resolving prompts...")
-
system_prompt_text = resolve_prompt(
system_prompt,
system_prompt_file_path,
None,
)
user_prompt_text = resolve_prompt(
- user_prompt, user_prompt_file_path, get_default_generator_user_prompt()
+ user_prompt,
+ user_prompt_file_path,
+ get_default_generator_user_prompt(),
)
if not user_prompt_text:
raise ValueError(
- "User prompt cannot be empty. Please provide a valid prompt."
+ "User prompt cannot be empty. Please provide a valid prompt.",
)
if verbose:
if system_prompt_text:
logger.info(
- f"System prompt length: {len(system_prompt_text)} characters"
+ "System prompt length: %d characters", len(system_prompt_text)
)
if user_prompt_text:
logger.info(
- f"User prompt length: {len(user_prompt_text)} characters"
+ "User prompt length: %d characters", len(user_prompt_text)
)
- if verbose:
- logger.info("Initializing LLM...")
+ logger.debug(
+ "CLI configuration for generation",
+ extra={
+ "provider": os.getenv("LLM_PROVIDER", "groq"),
+ "simple_mode": simple,
+ "max_iterations": max_iterations,
+ "criteria_threshold": criteria_threshold,
+ },
+ )
llm = create_llm()
if verbose:
logger.info("Creating generator agent...")
generator = GeneratorAgent(
- llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text
+ llm=llm,
+ system_prompt=system_prompt_text,
+ user_prompt=user_prompt_text,
)
if not simple:
@@ -280,30 +281,33 @@ def generate(
)
if verbose:
- logger.info(f"Generating description for: {validated_image_path.name}")
+ logger.info("Generating description for: %s", validated_image_path.name)
if simple:
- description = generator.generate(image=image)
+ description = generator.generate(
+ image=image,
+ return_intermediate_steps=False,
+ )
else:
description = orchestrator.run(image=image)
- if verbose:
+ if verbose and isinstance(description, str):
logger.info("Description generated successfully!")
- logger.info(f"Description length: {len(description)} characters")
+ logger.info("Description length: %d characters", len(description))
logger.info("-" * 50)
print(description)
- return None
+ return
- except (FileNotFoundError, ValueError, IOError) as e:
- logger.error(f"Could not load image file: {e}", exc_info=True)
+ except (OSError, FileNotFoundError, ValueError) as e:
+ logger.error("Could not load image file: %s", e, exc_info=True)
sys.exit(1)
except RuntimeError as e:
- logger.error(f"Generation failed: {e}", exc_info=True)
+ logger.error("Generation failed: %s", e, exc_info=True)
sys.exit(1)
except Exception as e:
- logger.error(f"Unexpected error: {e}", exc_info=True)
+ logger.error("Unexpected error: %s", e, exc_info=True)
sys.exit(1)
@staticmethod
@@ -311,7 +315,7 @@ def evaluate(
image_path: str,
description: str | None = None,
description_file_path: str | None = None,
- criteria: List[str] = ["coherence", "fluency", "consistency", "relevance"],
+ criteria: list[str] | None = None,
verbose: bool = False,
) -> None:
"""
@@ -332,27 +336,25 @@ def evaluate(
ValueError: If arguments are invalid or conflicting
RuntimeError: If evaluation fails
"""
- try:
- if verbose:
- logger.info(f"Validating image: {image_path}")
+ logger.info("Starting description evaluation...")
+ if criteria is None:
+ criteria = ["coherence", "fluency", "consistency", "relevance"]
+ try:
validated_image_path = validate_image_path(image_path)
image = Image.open(validated_image_path)
- if verbose:
- logger.info("Resolving description...")
-
description_text = resolve_description(
description,
description_file_path,
)
if not description_text or description_text.strip() == "":
raise ValueError(
- "Description cannot be empty. Please provide a valid description."
+ "Description cannot be empty. Please provide a valid description.",
)
if verbose:
- logger.info(f"Description length: {len(description_text)} characters")
+ logger.info("Description length: %d characters", len(description_text))
if not criteria or len(criteria) == 0:
raise ValueError("Criteria list cannot be empty.")
@@ -361,14 +363,22 @@ def evaluate(
invalid_criteria = [c for c in criteria if c not in valid_criteria]
if invalid_criteria:
raise ValueError(
- f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}"
+ f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}",
)
if verbose:
- logger.info(f"Evaluation criteria: {', '.join(criteria)}")
+ logger.info("Evaluation criteria: %s", ", ".join(criteria))
if verbose:
logger.info("Initializing LLM...")
+
+ logger.debug(
+ "CLI configuration for evaluation",
+ extra={
+ "provider": os.getenv("LLM_PROVIDER", "groq"),
+ "criteria": criteria,
+ },
+ )
llm = create_llm()
if verbose:
@@ -379,7 +389,7 @@ def evaluate(
)
if verbose:
- logger.info(f"Evaluating description for: {validated_image_path.name}")
+ logger.info("Evaluating description for: %s", validated_image_path.name)
evaluation = evaluator.evaluate(
description=description_text,
image=image,
@@ -387,7 +397,7 @@ def evaluate(
if verbose:
logger.info("Evaluation completed successfully!")
- logger.info(f"Number of criteria evaluated: {len(evaluation)}")
+ logger.info("Number of criteria evaluated: %d", len(evaluation))
logger.info("-" * 50)
for eval in evaluation:
@@ -397,20 +407,20 @@ def evaluate(
print(f"Reasoning: {eval.reasoning}")
print("-" * 50)
- return None
+ return
except FileNotFoundError as e:
- logger.error(f"File not found: {e}", exc_info=True)
+ logger.error("File not found: %s", e, exc_info=True)
sys.exit(1)
except ValueError as e:
- logger.error(f"Invalid input: {e}", exc_info=True)
+ logger.error("Invalid input: %s", e, exc_info=True)
sys.exit(1)
except Exception as e:
- logger.error(f"Evaluation failed: {e}", exc_info=True)
+ logger.error("Evaluation failed: %s", e, exc_info=True)
sys.exit(1)
-def cli():
+def cli() -> None:
"""
CLI entrypoint.
"""
diff --git a/src/earth_reach/config/__init__.py b/src/earth_reach/config/__init__.py
new file mode 100644
index 0000000..0b7eb79
--- /dev/null
+++ b/src/earth_reach/config/__init__.py
@@ -0,0 +1,6 @@
+"""
+Configuration package.
+
+Contains configuration modules for evaluation criteria, logging setup,
+and other system-wide settings.
+"""
diff --git a/src/earth_reach_agent/config/criteria.py b/src/earth_reach/config/criteria.py
similarity index 51%
rename from src/earth_reach_agent/config/criteria.py
rename to src/earth_reach/config/criteria.py
index d0aab28..282b053 100644
--- a/src/earth_reach_agent/config/criteria.py
+++ b/src/earth_reach/config/criteria.py
@@ -1,5 +1,11 @@
+"""
+Quality criteria module.
+
+Defines the evaluation criteria used to assess the quality of generated
+weather chart descriptions across multiple dimensions including coherence and accuracy.
+"""
+
from enum import Enum
-from typing import List
class QualityCriteria(Enum):
@@ -9,5 +15,5 @@ class QualityCriteria(Enum):
RELEVANCE = "relevance"
@classmethod
- def list(cls) -> List[str]:
+ def list(cls) -> list[str]:
return [criterion.value for criterion in cls]
diff --git a/src/earth_reach/config/logging.py b/src/earth_reach/config/logging.py
new file mode 100644
index 0000000..15791c8
--- /dev/null
+++ b/src/earth_reach/config/logging.py
@@ -0,0 +1,39 @@
+"""
+Centralized logging configuration.
+"""
+
+import logging
+import os
+
+
+def setup_logging(level: str | None = None) -> None:
+ """
+ Configure logging for the application.
+
+ Args:
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+ If None, uses INFO level or LOG_LEVEL environment variable.
+ """
+ if level is None:
+ level = os.getenv("LOG_LEVEL", "INFO").upper()
+
+ logging.basicConfig(
+ level=getattr(logging, level, logging.INFO),
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ )
+
+
+def get_logger(name: str) -> logging.Logger:
+ """
+ Get a logger instance with the specified name.
+
+ Args:
+ name: Logger name, typically __name__ from the calling module.
+
+ Returns:
+ Configured logger instance.
+ """
+ return logging.getLogger(name)
+
+
+setup_logging()
diff --git a/src/earth_reach/core/__init__.py b/src/earth_reach/core/__init__.py
new file mode 100644
index 0000000..92c644e
--- /dev/null
+++ b/src/earth_reach/core/__init__.py
@@ -0,0 +1,6 @@
+"""
+Core package.
+
+Contains the core components of the dual-LLM framework: generator and evaluator
+agents, orchestrator, and data extraction utilities.
+"""
diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach/core/evaluator.py
similarity index 77%
rename from src/earth_reach_agent/core/evaluator.py
rename to src/earth_reach/core/evaluator.py
index 81c4283..b744dee 100644
--- a/src/earth_reach_agent/core/evaluator.py
+++ b/src/earth_reach/core/evaluator.py
@@ -1,20 +1,29 @@
-import logging
+"""
+Evaluator Agent module.
+
+This module provides the main structure and classes for evaluating weather chart descriptions
+automatically against a set of quality criteria.
+"""
+
import re
+
from dataclasses import MISSING, dataclass, fields
from io import BytesIO
-from typing import Any, List, Union, get_args, get_origin
+from typing import Any, Union, get_args, get_origin
import earthkit.plots as ekp
+
from PIL import Image
from PIL.ImageFile import ImageFile
-from earth_reach_agent.core.generator import FigureMetadata
-from earth_reach_agent.core.llm import BaseLLM, create_llm
-from earth_reach_agent.core.prompts.evaluator import (
+from earth_reach.config.logging import get_logger
+from earth_reach.core.generator import FigureMetadata
+from earth_reach.core.llm import LLMInterface, create_llm
+from earth_reach.core.prompts.evaluator import (
get_default_criterion_evaluator_user_prompt,
)
-logger = logging.getLogger(__name__)
+logger = get_logger(__name__)
@dataclass
@@ -40,16 +49,20 @@ def is_score_valid(self) -> bool:
"""
return 0 <= self.score <= 5
- def __post_init__(self):
+ def __post_init__(self) -> None:
if not self.is_score_valid():
raise ValueError("Score must be between 0 and 5.")
class CriterionEvaluator:
- """Evaluator class for evaluating the quality of text based on a specified criterion."""
+ """Evaluator class for evaluating the quality of weather descriptions based on a specified criterion."""
def __init__(
- self, criterion: str, llm: BaseLLM, system_prompt: str | None, user_prompt: str
+ self,
+ criterion: str,
+ llm: LLMInterface,
+ system_prompt: str | None,
+ user_prompt: str,
) -> None:
if criterion not in ["coherence", "fluency", "consistency", "relevance"]:
raise ValueError(f"Unsupported criterion: {criterion}")
@@ -67,18 +80,18 @@ def evaluate(
) -> CriterionEvaluatorOutput:
if figure is not None and image is not None:
raise ValueError(
- "Only one of 'figure' or 'image' can be provided, not both."
+ "Only one of 'figure' or 'image' can be provided, not both.",
)
if figure is not None:
- # TODO(medium): If metadata extraction fails, continue without it
metadata = self._get_metadata_from_figure(figure)
self.user_prompt = self._update_user_prompt_with_metadata(
- self.user_prompt, metadata
+ self.user_prompt,
+ metadata,
)
image = self._get_image_from_figure(figure)
elif image is None and figure is None:
raise ValueError(
- "Either 'figure' or 'image' must be provided to generate a description."
+ "Either 'figure' or 'image' must be provided to generate a description.",
)
try:
user_prompt = (
@@ -128,14 +141,16 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput:
content = match.group(1).strip()
if content:
converted_value = self.convert_to_field_type(
- content, field_name, field_type
+ content,
+ field_name,
+ field_type,
)
extracted_values[field_name] = converted_value
except Exception as e:
- parsing_errors.append(f"Failed to parse field '{field_name}': {str(e)}")
+ parsing_errors.append(f"Failed to parse field '{field_name}': {e!s}")
logger.warning(
- f"Parsing errors encountered: {parsing_errors}"
+ f"Parsing errors encountered: {parsing_errors}",
) if parsing_errors else None
required_fields = [
@@ -150,18 +165,21 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput:
missing_required = [f for f in required_fields if f not in extracted_values]
if missing_required:
raise ValueError(
- f"Missing required fields in XML response: {missing_required}"
+ f"Missing required fields in XML response: {missing_required}",
)
try:
return CriterionEvaluatorOutput(name=self.criterion, **extracted_values)
except Exception as e:
raise Exception(
- f"Failed to create CriterionEvaluatorOutput instance: {str(e)}"
- )
+ f"Failed to create CriterionEvaluatorOutput instance: {e!s}",
+ ) from e
def convert_to_field_type(
- self, content: str, field_name: str, field_type: Any
+ self,
+ content: str,
+ field_name: str,
+ field_type: Any,
) -> Any:
"""
Convert string content to the appropriate type based on field type annotation.
@@ -190,36 +208,35 @@ def convert_to_field_type(
if field_type is int:
try:
return int(content)
- except ValueError:
+ except ValueError as e:
raise ValueError(
- f"Cannot convert '{content}' to integer for field '{field_name}'"
- )
+ f"Cannot convert '{content}' to integer for field '{field_name}'",
+ ) from e
elif field_type is float:
try:
return float(content)
- except ValueError:
+ except ValueError as e:
raise ValueError(
- f"Cannot convert '{content}' to float for field '{field_name}'"
- )
+ f"Cannot convert '{content}' to float for field '{field_name}'",
+ ) from e
elif field_type is bool:
lower_content = content.lower().strip()
if lower_content in ("true", "1", "yes", "on", "y"):
return True
- elif lower_content in ("false", "0", "no", "off", "n"):
+ if lower_content in ("false", "0", "no", "off", "n"):
return False
- else:
- raise ValueError(
- f"Cannot convert '{content}' to boolean for field '{field_name}'. "
- f"Expected: true/false, 1/0, yes/no, on/off, y/n"
- )
+ raise ValueError(
+ f"Cannot convert '{content}' to boolean for field '{field_name}'. "
+ f"Expected: true/false, 1/0, yes/no, on/off, y/n",
+ )
elif field_type is str:
return content
else:
- if hasattr(field_type, "__call__"):
+ if callable(field_type):
try:
return field_type(content)
except Exception:
@@ -252,7 +269,9 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata:
return metadata
def _update_user_prompt_with_metadata(
- self, user_prompt: str, metadata: FigureMetadata
+ self,
+ user_prompt: str,
+ metadata: FigureMetadata,
) -> str:
"""
Update the user prompt with metadata extracted from the figure.
@@ -269,7 +288,8 @@ def _update_user_prompt_with_metadata(
value = getattr(metadata, field_info.name)
if value is not None:
description = field_info.metadata.get(
- "description", "No description available"
+ "description",
+ "No description available",
)
metadata_items.append(f"- {field_info.name} ({description}): {value}")
@@ -297,18 +317,22 @@ def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile:
buffer.seek(0)
return Image.open(buffer)
+ def append_user_prompt(self, text: str) -> None:
+ """Append additional text to the user prompt."""
+ self.user_prompt += f"\n\n{text.strip()}"
+
class CriterionEvaluatorFactory:
"""Factory class for creating single criterion evaluator agents."""
@staticmethod
- def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator:
+ def create(criterion: str, llm: LLMInterface | None = None) -> CriterionEvaluator:
"""
Create a CriterionEvaluator instance based on the provided criterion.
Args:
criterion (str): Criterion name to create evaluators for.
- llm (BaseLLM | None): Optional LLM instance to use for evaluation.
+ llm (LLMInterface | None): Optional LLM instance to use for evaluation.
Returns:
CriterionEvaluator: CriterionEvaluator instance.
@@ -322,20 +346,24 @@ def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator:
user_prompt = get_default_criterion_evaluator_user_prompt(criterion)
return CriterionEvaluator(
- criterion=criterion, llm=llm, system_prompt=None, user_prompt=user_prompt
+ criterion=criterion,
+ llm=llm,
+ system_prompt=None,
+ user_prompt=user_prompt,
)
class EvaluatorAgent:
"""Agent class for evaluating the quality of weather chart descriptions."""
- def __init__(self, criteria: List[str], llm: BaseLLM | None = None) -> None:
+ def __init__(self, criteria: list[str], llm: LLMInterface | None = None) -> None:
"""
Initialize the EvaluatorAgent.
Args:
criteria (List[str]): List of criteria to evaluate against.
Supported criteria: "coherence", "fluency", "consistency", "relevance".
+ llm (LLMInterface | None): Optional LLM instance to use for evaluation.
Raises:
ValueError: If an unsupported criterion is provided.
@@ -354,7 +382,7 @@ def __init__(self, criteria: List[str], llm: BaseLLM | None = None) -> None:
]
except Exception as e:
raise RuntimeError(
- f"Failed to create evaluators for criteria {criteria}: {e}"
+ f"Failed to create evaluators for criteria {criteria}: {e}",
) from e
def evaluate(
@@ -362,7 +390,7 @@ def evaluate(
description: str,
figure: ekp.Figure | None = None,
image: ImageFile | None = None,
- ) -> List[CriterionEvaluatorOutput]:
+ ) -> list[CriterionEvaluatorOutput]:
"""
Evaluate the given text against the specified criteria.
@@ -374,43 +402,34 @@ def evaluate(
"""
if figure is not None and image is not None:
raise ValueError(
- "Only one of 'figure' or 'image' can be provided, not both."
+ "Only one of 'figure' or 'image' can be provided, not both.",
)
try:
evaluations = []
for evaluator in self.evaluators:
+ logger.debug("Evaluating criterion: %s", evaluator.criterion)
result = evaluator.evaluate(
- description=description, figure=figure, image=image
+ description=description,
+ figure=figure,
+ image=image,
+ )
+ logger.debug(
+ "Criterion evaluation completed",
+ extra={
+ "criterion": result.name,
+ "score": result.score,
+ "max_score": 5,
+ },
)
evaluations.append(result)
+ logger.info("Evaluator successfully evaluated the description")
return evaluations
except Exception as e:
raise RuntimeError(f"Failed to evaluate description: {e}") from e
- def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool:
- """
- Check if the length of the text is lesser than the specified maximum length.
-
- Args:
- text (str): The text to check.
- max_length (int): The maximum length of the text.
-
- Returns:
- bool: True if the text length is valid, False otherwise.
- """
- return len(text) <= max_length
-
- def is_text_length_greater_than_min(self, text: str, min_length: int = 100) -> bool:
- """
- Check if the length of the text is greater than the specified minimum length.
-
- Args:
- text (str): The text to check.
- min_length (int): The minimum length of the text.
-
- Returns:
- bool: True if the text length is more than the minimum, False otherwise.
- """
- return min_length <= len(text)
+ def append_user_prompt(self, text: str) -> None:
+ """Append additional text to the user prompt of each criterion evaluator."""
+ for evaluator in self.evaluators:
+ evaluator.user_prompt += f"\n\n{text.strip()}"
diff --git a/src/earth_reach/core/extractors/__init__.py b/src/earth_reach/core/extractors/__init__.py
new file mode 100644
index 0000000..d9de6d0
--- /dev/null
+++ b/src/earth_reach/core/extractors/__init__.py
@@ -0,0 +1,10 @@
+"""
+Data extractors package.
+
+Contains modules for extracting meteorological information from GRIB files,
+including pressure data extractors that enhance prompt generation.
+
+Data extractors are optional experimental components that can be created and
+integrated with the system to improve the relevance and consistency of the
+descriptions, but they are not mandatory to the system's core functionality.
+"""
diff --git a/src/earth_reach/core/extractors/base_extractor.py b/src/earth_reach/core/extractors/base_extractor.py
new file mode 100644
index 0000000..c6fd77a
--- /dev/null
+++ b/src/earth_reach/core/extractors/base_extractor.py
@@ -0,0 +1,56 @@
+"""
+Base Data Extractor module.
+
+This module defines the abstract base class for data extractors.
+It provides a common interface and functionality for extracting meteorological features
+from GRIB files.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+import earthkit.data as ekd
+
+
+class BaseDataExtractor(ABC):
+ """Abstract base class for weather data extractors."""
+
+ @abstractmethod
+ def validate_data(self, data: ekd.FieldList) -> Any:
+ """
+ Parse, validate and return GRIB data.
+
+ Args:
+ data (ekd.FieldList): Input data to validate
+
+ Returns:
+ Any: Parsed and validated data
+
+ Raises:
+ ValueError: If validation fails
+ """
+
+ @abstractmethod
+ def extract(self, data: ekd.FieldList, **kwargs: Any) -> list[Any]:
+ """
+ Extract features from the input data.
+
+ Args:
+ data: Input data
+ **kwargs: Additional extraction parameters
+
+ Returns:
+ List of extracted features
+ """
+
+ @abstractmethod
+ def format_features_to_str(self, features: list[Any]) -> str:
+ """
+ Format extracted features into a prompt-friendly string.
+
+ Args:
+ features: List of extracted features
+
+ Returns:
+ str: Formatted string for prompt update
+ """
diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py
new file mode 100644
index 0000000..507edcc
--- /dev/null
+++ b/src/earth_reach/core/extractors/pressure_extractor.py
@@ -0,0 +1,205 @@
+"""
+Pressure-Center Data Extractor module.
+
+This module provides functionality to extract pressure centers (high and low pressure systems)
+from meteorological data, specifically mean sea level pressure fields in GRIB format.
+It uses simple local extrema detection to identify these centers.
+"""
+
+from dataclasses import dataclass
+from typing import Any
+
+import earthkit.data as ekd
+import numpy as np
+
+from scipy.ndimage import maximum_filter, minimum_filter
+
+from earth_reach.config.logging import get_logger
+from earth_reach.core.extractors.base_extractor import BaseDataExtractor
+
+logger = get_logger(__name__)
+
+
+@dataclass
+class PressureCenter:
+ """Data structure for a pressure center."""
+
+ center_type: str
+ latitude: float
+ longitude: float
+ center_value_hPa: float
+ grid_indices: tuple[int, int]
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ return {
+ "center_type": self.center_type,
+ "latitude": self.latitude,
+ "longitude": self.longitude,
+ "center_value_hPa": self.center_value_hPa,
+ "grid_indices": self.grid_indices,
+ }
+
+
+class PressureCenterDataExtractor(BaseDataExtractor):
+ """
+ Concrete implementation for extracting pressure centers from GRIB data.
+
+ Uses local extrema detection to identify high and low pressure centers
+ in mean sea level pressure fields.
+ """
+
+ def __init__(
+ self,
+ pressure_var_name: str = "msl",
+ neighborhood_size: int = 200,
+ ):
+ """
+ Initialize the pressure center extractor.
+
+ Args:
+ pressure_var_name: Name of pressure variable in GRIB data
+ neighborhood_size: Size of neighborhood for local extrema detection
+ """
+
+ self.neighborhood_size = neighborhood_size
+ self.pressure_var_name = pressure_var_name
+
+ def validate_data(
+ self, data: ekd.FieldList
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """
+ Validate and return GRIB data pressure field.
+
+ Args:
+ data (ekd.FieldList): GRIB data to validate
+
+ Returns:
+ Tuple of (data array, latitudes, longitudes)
+
+ Raises:
+ ValueError: If required pressure variable not found
+ """
+ try:
+ available_vars = [str(var) for var in data.metadata("shortName")]
+ if self.pressure_var_name not in available_vars:
+ raise ValueError(
+ f"Required variable '{self.pressure_var_name}' not found. "
+ f"Available variables: {', '.join(available_vars)}",
+ )
+
+ data_field = data.sel(shortName=self.pressure_var_name)[0]
+ if not isinstance(data_field, ekd.Field):
+ raise ValueError(
+ "Could not extract a valid pressure field from data.",
+ )
+ data_arr = data_field.to_numpy()
+ if data_arr is None or not isinstance(data_arr, np.ndarray):
+ raise ValueError("Data array is empty or not a valid numpy array.")
+
+ latlons = data_field.to_latlon()
+ lats = latlons["lat"]
+ lons = latlons["lon"]
+
+ if lats is None or lons is None:
+ raise ValueError("Could not extract latitude/longitude coordinates")
+
+ return data_arr, lats, lons
+
+ except ValueError as e:
+ logger.error("Pressure centers data extractor validation failed: %s", e)
+ raise
+
+ def extract(
+ self,
+ data: ekd.FieldList,
+ **kwargs: Any,
+ ) -> list[PressureCenter]:
+ """
+ Extract pressure centers from GRIB data.
+
+ Args:
+ data (ekd.FieldList): Input GRIB data
+ **kwargs: Additional extraction parameters
+
+ Returns:
+ List[PressureCenter]: List of PressureCenter objects
+ """
+ pressure_centers = []
+ try:
+ data_arr, lats, lons = self.validate_data(data)
+ local_min = data == minimum_filter(data_arr, size=self.neighborhood_size)
+ local_max = data == maximum_filter(data_arr, size=self.neighborhood_size)
+
+ if local_max is None or local_min is None:
+ raise ValueError(
+ "Could not find local extrema in the data.",
+ )
+
+ min_indices = np.where(local_min)
+ max_indices = np.where(local_max)
+ for i in range(len(min_indices[0])):
+ row, col = min_indices[0][i], min_indices[1][i]
+ pressure_centers.append(
+ PressureCenter(
+ center_type="low",
+ latitude=lats[row, col],
+ longitude=lons[row, col],
+ center_value_hPa=data_arr[row, col],
+ grid_indices=(row, col),
+ ),
+ )
+ for i in range(len(max_indices[0])):
+ row, col = max_indices[0][i], max_indices[1][i]
+ pressure_centers.append(
+ PressureCenter(
+ center_type="high",
+ latitude=lats[row, col],
+ longitude=lons[row, col],
+ center_value_hPa=data_arr[row, col],
+ grid_indices=(row, col),
+ ),
+ )
+
+ return pressure_centers
+ except Exception as e:
+ logger.error("Pressure center data extraction failed: %s", e)
+ return []
+
+ def format_features_to_str(self, features: list[PressureCenter]) -> str:
+ """Format extracted temperature features into a prompt-friendly string."""
+
+ output_str = "## Presure Center Extractor Output\n\n"
+ if not features:
+ output_str += "No pressure centers could be extracted.\n"
+ return output_str
+
+ output_str += (
+ "Information extracted about the pressure centers present on the map:\n\n"
+ )
+
+ low_pressure_centers = sorted(
+ [center for center in features if center.center_type == "low"],
+ key=lambda x: x.center_value_hPa,
+ )
+ high_pressure_centers = sorted(
+ [center for center in features if center.center_type == "high"],
+ key=lambda x: x.center_value_hPa,
+ reverse=True,
+ )
+ output_str += "**Low Pressure Centers**:\n"
+ for center in low_pressure_centers:
+ output_str += (
+ f"- {center.center_type.capitalize()} pressure center at "
+ f"({center.latitude:.2f}°N, {center.longitude:.2f}°E) "
+ f"with value {center.center_value_hPa:.2f} hPa.\n"
+ )
+ output_str += "\n**High Pressure Centers**:\n"
+ for center in high_pressure_centers:
+ output_str += (
+ f"- {center.center_type.capitalize()} pressure center at "
+ f"({center.latitude:.2f}°N, {center.longitude:.2f}°E) "
+ f"with value {center.center_value_hPa:.2f} hPa.\n"
+ )
+
+ return output_str
diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach/core/generator.py
similarity index 77%
rename from src/earth_reach_agent/core/generator.py
rename to src/earth_reach/core/generator.py
index a4860d9..3622760 100644
--- a/src/earth_reach_agent/core/generator.py
+++ b/src/earth_reach/core/generator.py
@@ -1,13 +1,24 @@
+"""
+Generator Agent module.
+
+This module provides the main structures and classes for generating descriptions from
+weather chart images or eathkit-plots figures.
+"""
+
import re
+
from dataclasses import dataclass, field, fields
from io import BytesIO
-from typing import List
import earthkit.plots as ekp
+
from PIL import Image
from PIL.ImageFile import ImageFile
-from earth_reach_agent.core.llm import BaseLLM
+from earth_reach.config.logging import get_logger
+from earth_reach.core.llm import LLMInterface
+
+logger = get_logger(__name__)
@dataclass
@@ -15,7 +26,8 @@ class FigureMetadata:
"""Metadata extracted from a figure."""
title: str | None = field(
- default=None, metadata={"description": "Figure title or heading"}
+ default=None,
+ metadata={"description": "Figure title or heading"},
)
xlabel: str | None = field(
default=None,
@@ -26,9 +38,10 @@ class FigureMetadata:
metadata={"description": "Y-axis label describing the vertical dimension"},
)
domain: str | None = field(
- default=None, metadata={"description": "Geographic domain of the figure"}
+ default=None,
+ metadata={"description": "Geographic domain of the figure"},
)
- variables: List[str] | None = field(
+ variables: list[str] | None = field(
default=None,
metadata={"description": "Key variables shown in the figure"},
)
@@ -51,6 +64,7 @@ class GeneratorOutput:
step_2: str | None = None
step_3: str | None = None
step_4: str | None = None
+ step_5: str | None = None
final_description: str | None = None
def is_complete(self) -> bool:
@@ -65,7 +79,7 @@ def is_complete(self) -> bool:
for field in fields(self)
)
- def get_missing_fields(self) -> List[str]:
+ def get_missing_fields(self) -> list[str]:
"""
Return list of field names that were not successfully parsed.
@@ -109,13 +123,16 @@ class GeneratorAgent:
"""GeneratorAgent class for generating weather charts scientific descriptions."""
def __init__(
- self, llm: BaseLLM, system_prompt: str | None, user_prompt: str
+ self,
+ llm: LLMInterface,
+ system_prompt: str | None,
+ user_prompt: str,
) -> None:
"""
- Initialize the GeneratorAgent with a BaseLLM instance and prompts.
+ Initialize the GeneratorAgent with a LLMInterface instance and prompts.
Args:
- llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions.
+ llm (LLMInterface): An instance of a LLMInterface to handle LLM interactions.
system_prompt (str | None): Optional system prompt to guide the style of the LLM.
user_prompt (str): The user prompt containing the instructions for description generation.
"""
@@ -124,14 +141,18 @@ def __init__(
self.user_prompt = user_prompt
def generate(
- self, figure: ekp.Figure | None = None, image: ImageFile | None = None
- ) -> str:
+ self,
+ figure: ekp.Figure | None = None,
+ image: ImageFile | None = None,
+ return_intermediate_steps: bool = False,
+ ) -> str | GeneratorOutput:
"""
Generate a structured weather description using the LLM.
Args:
figure (Figure | None): Optional figure to include in the request. Can't be used with image.
image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure.
+ return_intermediate_steps (bool): If True, return intermediate steps in the response.
Returns:
str: The final weather description.
@@ -143,18 +164,18 @@ def generate(
"""
if figure is not None and image is not None:
raise ValueError(
- "Only one of 'figure' or 'image' can be provided, not both."
+ "Only one of 'figure' or 'image' can be provided, not both.",
)
if figure is not None:
- # TODO(medium): If metadata extraction failes, continue without it
metadata = self._get_metadata_from_figure(figure)
self.user_prompt = self._update_user_prompt_with_metadata(
- self.user_prompt, metadata
+ self.user_prompt,
+ metadata,
)
image = self._get_image_from_figure(figure)
elif image is None and figure is None:
raise ValueError(
- "Either 'figure' or 'image' must be provided to generate a description."
+ "Either 'figure' or 'image' must be provided to generate a description.",
)
try:
@@ -164,19 +185,33 @@ def generate(
image=image,
)
+ logger.debug("Parsing LLM response for structured output")
parsed_output = self.parse_llm_response(response)
if not parsed_output.is_complete():
+ logger.warning(
+ "LLM response parsing incomplete",
+ extra={
+ "missing_fields": parsed_output.get_missing_fields(),
+ "total_fields": len(list(fields(parsed_output))),
+ },
+ )
raise ValueError(
"Parsed output is incomplete. Missing fields: "
- f"{parsed_output.get_missing_fields()}"
+ f"{parsed_output.get_missing_fields()}",
)
+ if return_intermediate_steps:
+ return parsed_output
+
description = parsed_output.final_description
+ if not description or not description.strip():
+ raise ValueError("Final description is empty or None.")
except Exception as e:
raise RuntimeError(f"Failed to generate response: {e}") from e
- return description # type: ignore[return-value]
+ logger.info("Generator successfully generated a description")
+ return description
def parse_llm_response(self, response: str) -> GeneratorOutput:
"""
@@ -237,10 +272,23 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata:
metadata.xlabel = axes[0].get_xlabel()
metadata.ylabel = axes[0].get_ylabel()
metadata.domain = figure._domain
+
+ logger.debug(
+ "Extracted metadata from figure",
+ extra={
+ "title": metadata.title or "None",
+ "domain": metadata.domain or "None",
+ "xlabel": metadata.xlabel or "None",
+ "ylabel": metadata.ylabel or "None",
+ },
+ )
+
return metadata
def _update_user_prompt_with_metadata(
- self, user_prompt: str, metadata: FigureMetadata
+ self,
+ user_prompt: str,
+ metadata: FigureMetadata,
) -> str:
"""
Update the user prompt with metadata extracted from the figure.
@@ -257,14 +305,15 @@ def _update_user_prompt_with_metadata(
value = getattr(metadata, field_info.name)
if value is not None:
description = field_info.metadata.get(
- "description", "No description available"
+ "description",
+ "No description available",
)
metadata_items.append(f"- {field_info.name} ({description}): {value}")
if not metadata_items:
return user_prompt
- metadata_str = "# FIGURE METADATA\n\n"
+ metadata_str = "## FIGURE METADATA\n\n"
metadata_str += "The following metadata was extracted from the figure:\n\n"
metadata_str += "\n".join(metadata_items)
diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py
new file mode 100644
index 0000000..559120b
--- /dev/null
+++ b/src/earth_reach/core/llm.py
@@ -0,0 +1,413 @@
+"""
+LLM Interface module.
+
+Provides an abstract interface and concrete implementations for various
+Large Language Model providers including OpenAI, Google Gemini, and Anthropic Claude.
+"""
+
+import os
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+import openai
+
+from google import genai
+from google.genai import types
+from PIL.ImageFile import ImageFile
+
+from earth_reach.config.logging import get_logger
+from earth_reach.core.utils import img_to_base64, img_to_bytes
+
+logger = get_logger(__name__)
+
+
+class LLMInterface(ABC):
+ """Abstract base class defining the interface for all LLM provider implementations."""
+
+ @property
+ @abstractmethod
+ def provider_name(self) -> str:
+ """Must be implemented by subclasses to return the provider name."""
+
+ @abstractmethod
+ def generate(
+ self,
+ user_prompt: str,
+ system_prompt: str | None = None,
+ image: ImageFile | None = None,
+ ) -> str:
+ """
+ Generate a response from the LLM based on the user prompt and optional system prompt.
+
+ Args:
+ user_prompt (str): The prompt provided by the user to define the task.
+ system_prompt (str | None): An optional system prompt to guide the model's response.
+ image: Optional image to include in the request.
+
+ Returns:
+ str: The generated response content from the LLM.
+
+ Raises:
+ ValueError: If user_prompt is empty/None or if the API response is empty.
+ RuntimeError: For other run-time errors.
+ """
+
+
+class OpenAICompatibleLLM(LLMInterface):
+ """Base class for OpenAI-compatible LLM implementations (Groq, OpenAI, etc.)."""
+
+ def __init__(
+ self,
+ model_name: str,
+ base_url: str,
+ api_key: str | None = None,
+ ) -> None:
+ """
+ Initialize the LLM with a model name and optional keyword arguments.
+
+ Args:
+ model_name (str): The name of the model to use.
+ base_url (str): The base URL for the LLM API.
+ api_key (str | None): The API key for authentication with the LLM provider.
+ **kwargs: Additional keyword arguments for the LLM configuration.
+ """
+ self.model_name = model_name
+ self.base_url = base_url
+ self.api_key = api_key
+
+ self.client = openai.OpenAI(
+ base_url=base_url,
+ api_key=api_key,
+ )
+
+ @property
+ def provider_name(self):
+ return "openAICompatible"
+
+ def generate(
+ self,
+ user_prompt: str,
+ system_prompt: str | None = None,
+ image: ImageFile | None = None,
+ ) -> str:
+ """
+ Generate a response from the LLM API based on the user prompt and optional system prompt.
+
+ Args:
+ user_prompt (str): The prompt provided by the user to define the task.
+ system_prompt (str | None): An optional system prompt to guide the model's response.
+ image: Optional image to include in the request (will be converted to base64).
+
+ Returns:
+ str: The generated response content from the LLM.
+
+ Raises:
+ ValueError: If user_prompt is empty/None or if the API response is empty.
+ RuntimeError: For other run-time errors.
+ """
+
+ if not user_prompt or not user_prompt.strip():
+ raise ValueError("user_prompt cannot be empty or None")
+
+ messages: list[Any] = []
+
+ if system_prompt and system_prompt.strip():
+ messages.append({"role": "system", "content": system_prompt.strip()})
+
+ try:
+ if image:
+ base64_image = img_to_base64(img=image)
+ if not base64_image:
+ raise ValueError("Failed to convert image to base64")
+
+ user_content: Any = [
+ {"type": "text", "text": user_prompt.strip()},
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": f"data:image/jpeg;base64,{base64_image}",
+ },
+ },
+ ]
+ else:
+ user_content = user_prompt.strip()
+
+ messages.append({"role": "user", "content": user_content})
+
+ except Exception as e:
+ raise ValueError(f"Failed to process input data: {e}") from e
+
+ try:
+ response = self.client.chat.completions.create(
+ model=self.model_name,
+ messages=messages,
+ )
+
+ content = response.choices[0].message.content
+
+ if not content or not isinstance(content, str) or not content.strip():
+ raise ValueError(
+ "The generated response content is empty or not a string"
+ )
+
+ logger.info(
+ "LLM API call completed successfully",
+ extra={
+ "provider": self.provider_name,
+ "model": self.model_name,
+ "input_length": len(user_prompt),
+ "output_length": len(content),
+ "has_image": image is not None,
+ },
+ )
+
+ return content.strip()
+
+ except ValueError:
+ raise
+ except Exception as e:
+ logger.error(
+ "LLM API call failed",
+ extra={
+ "provider": self.provider_name,
+ "model": self.model_name,
+ },
+ exc_info=True,
+ )
+ raise RuntimeError("LLM API call failed") from e
+
+ def __repr__(self) -> str:
+ return f"LLM(model_name={self.model_name}, base_url={self.base_url})"
+
+
+class GroqLLM(OpenAICompatibleLLM):
+ """Implementation of the LLMInterface for Groq LLM API Provider."""
+
+ def __init__(self, model_name: str, api_key: str | None = None) -> None:
+ """Initialize the Groq LLM with a model name and optional API key.
+
+ Args:
+ model_name (str): The name of the Groq model to use.
+ api_key (str | None): The API key for authentication with the Groq API.
+
+ Raises:
+ AssertionError: If the API key is not provided and not found in environment variables.
+ """
+
+ if not api_key:
+ import os
+
+ api_key = os.environ.get("GROQ_API_KEY", None)
+ if not api_key:
+ raise AssertionError(
+ "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument.",
+ )
+
+ super().__init__(
+ model_name=model_name,
+ api_key=api_key,
+ base_url="https://api.groq.com/openai/v1",
+ )
+
+ @property
+ def provider_name(self):
+ return "groq"
+
+
+class OpenAILLM(OpenAICompatibleLLM):
+ """Implementation of the LLMInterface for OpenAI API Provider."""
+
+ def __init__(self, model_name: str, api_key: str | None = None) -> None:
+ """Initialize the OpenAI LLM with a model name and optional API key.
+
+ Args:
+ model_name (str): The name of the OpenAI model to use.
+ api_key (str | None): The API key for authentication with the OpenAI API.
+
+ Raises:
+ AssertionError: If the API key is not provided and not found in environment variables.
+ """
+
+ if not api_key:
+ import os
+
+ api_key = os.environ.get("OPENAI_API_KEY", None)
+ if not api_key:
+ raise AssertionError(
+ "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument.",
+ )
+
+ super().__init__(
+ model_name=model_name,
+ api_key=api_key,
+ base_url="https://api.openai.com/v1",
+ )
+
+ @property
+ def provider_name(self):
+ return "openAI"
+
+
+class GeminiLLM(LLMInterface):
+ """Implementation of the LLMInterface for Google Gemini API Provider."""
+
+ def __init__(self, model_name: str, api_key: str | None = None) -> None:
+ """Initialize the Gemini LLM with a model name and optional API key.
+
+ Args:
+ model_name (str): The name of the Gemini model to use.
+ api_key (str | None): The API key for authentication with the Gemini API.
+
+ Raises:
+ AssertionError: If the API key is not provided and not found in environment variables.
+ """
+
+ if not api_key:
+ api_key = os.environ.get("GEMINI_API_KEY", None)
+ if not api_key:
+ raise AssertionError(
+ "GEMINI_API_KEY not set. Please set it in your environment variables, or pass it as an argument.",
+ )
+
+ self.model_name = model_name
+ self.api_key = api_key
+ self.client = genai.Client(api_key=api_key)
+
+ @property
+ def provider_name(self):
+ return "gemini"
+
+ def generate(
+ self,
+ user_prompt: str,
+ system_prompt: str | None = None,
+ image: ImageFile | None = None,
+ ) -> str:
+ """
+ Generate a response from the Gemini API based on the user prompt and optional system prompt.
+
+ Args:
+ user_prompt (str): The prompt provided by the user to define the task.
+ system_prompt (str | None): An optional system prompt to guide the model's response.
+ image: Optional image to include in the request (ImageFile).
+
+ Returns:
+ str: The generated response content from the Gemini API.
+
+ Raises:
+ ValueError: If user_prompt is empty/None or if the API response is empty.
+ RuntimeError: For other run-time errors.
+ """
+
+ if not user_prompt or not user_prompt.strip():
+ raise ValueError("user_prompt cannot be empty or None")
+
+ try:
+ full_prompt = user_prompt.strip()
+ if system_prompt and system_prompt.strip():
+ full_prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}"
+
+ contents: list[Any] = []
+ if image:
+ image_bytes = img_to_bytes(image)
+ if not image_bytes:
+ raise ValueError("Failed to convert image to bytes")
+
+ contents.append(
+ types.Part.from_bytes(
+ data=image_bytes,
+ mime_type="image/png",
+ ),
+ )
+ contents.append(full_prompt)
+
+ except Exception as e:
+ raise ValueError(f"Failed to process input data: {e}") from e
+
+ try:
+ response = self.client.models.generate_content(
+ model=self.model_name,
+ contents=contents,
+ )
+
+ content = response.text
+
+ if not content or not isinstance(content, str) or not content.strip():
+ raise ValueError(
+ "The generated response content is empty or not a string"
+ )
+
+ logger.info(
+ "LLM API call completed successfully",
+ extra={
+ "provider": self.provider_name,
+ "model": self.model_name,
+ "input_length": len(user_prompt),
+ "output_length": len(content),
+ "has_image": image is not None,
+ },
+ )
+
+ return content.strip()
+
+ except ValueError:
+ raise
+ except Exception as e:
+ logger.error(
+ "LLM API call failed",
+ extra={
+ "provider": self.provider_name,
+ "model": self.model_name,
+ },
+ exc_info=True,
+ )
+ raise RuntimeError("LLM API call failed") from e
+
+ def __repr__(self) -> str:
+ return f"GeminiLLM(model_name={self.model_name})"
+
+
+def create_llm(provider: str = "groq", model_name: str | None = None) -> LLMInterface:
+ """
+ Create and return LLM instance.
+
+ This function can be modified to support different LLM configurations
+ or to read from environment variables/config files.
+
+ Args:
+ provider (str): LLM provider name (default: "groq")
+ model_name (str | None): Specific model name to use (default: None, uses provider's default)
+
+ Returns:
+ LLMInterface: Configured LLM instance
+ """
+ if provider.lower() == "groq":
+ api_key = os.getenv("GROQ_API_KEY")
+ if not api_key:
+ raise ValueError("GROQ_API_KEY environment variable is not set.")
+
+ if model_name is None:
+ model_name = "meta-llama/llama-4-maverick-17b-128e-instruct"
+ return GroqLLM(model_name=model_name, api_key=api_key)
+
+ if provider.lower() == "openai":
+ api_key = os.getenv("OPENAI_API_KEY")
+ if not api_key:
+ raise ValueError("OPENAI_API_KEY environment variable is not set.")
+ if model_name is None:
+ model_name = "o4-mini-2025-04-16"
+ return OpenAILLM(model_name=model_name, api_key=api_key)
+
+ if provider.lower() == "gemini":
+ api_key = os.getenv("GEMINI_API_KEY")
+ if not api_key:
+ raise ValueError("GEMINI_API_KEY environment variable is not set.")
+
+ if model_name is None:
+ model_name = "gemini-2.5-flash"
+ return GeminiLLM(model_name=model_name, api_key=api_key)
+
+ raise ValueError(
+ f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai', 'gemini'.",
+ )
diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py
new file mode 100644
index 0000000..5936f39
--- /dev/null
+++ b/src/earth_reach/core/orchestrator.py
@@ -0,0 +1,271 @@
+"""
+Orchestrator module.
+
+This module provides the orchestrator class, driving the generator and evaluator successive
+interactions to generate high-quality weather chart descriptions.
+"""
+
+import earthkit.data as ekd
+import earthkit.plots as ekp
+
+from PIL.ImageFile import ImageFile
+
+from earth_reach.config.logging import get_logger
+from earth_reach.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent
+from earth_reach.core.extractors.base_extractor import (
+ BaseDataExtractor,
+)
+from earth_reach.core.generator import GeneratorAgent, GeneratorOutput
+from earth_reach.core.prompts.orchestrator import get_default_feedback_template
+
+logger = get_logger(__name__)
+
+
+class Orchestrator:
+ """Orchestrates Generator and Evaluator agents to create weather chart descriptions."""
+
+ def __init__(
+ self,
+ generator_agent: GeneratorAgent,
+ evaluator_agent: EvaluatorAgent,
+ data_extractors: list[BaseDataExtractor] | None = None,
+ max_iterations: int = 3,
+ criteria_threshold: int = 4,
+ feedback_template: str | None = None,
+ ) -> None:
+ """
+ Initialize the orchestrator with generator and evaluator agents.
+
+ Args:
+ generator_agent: Instance of GeneratorAgent for generating descriptions
+ evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions
+ data_extractors: List of BaseDataExtractor instances for extracting features
+ max_iterations: Maximum number of iterations for generating descriptions
+ criteria_threshold: Minimum score for evaluation criteria to pass
+ feedback_template: Template for feedback to the generator agent (optional)
+ """
+ self.generator_agent = generator_agent
+ self.evaluator_agent = evaluator_agent
+ self.data_extractors = data_extractors if data_extractors is not None else []
+
+ self.max_iterations = max_iterations
+ if self.max_iterations <= 0:
+ raise ValueError("max_iterations must be greater than 0")
+
+ self.criteria_threshold = criteria_threshold
+ if self.criteria_threshold < 0 or self.criteria_threshold > 5:
+ raise ValueError("criteria_threshold must be between 0 and 5")
+
+ self.feedback_template = feedback_template or get_default_feedback_template()
+ self.criteria_limits_acknowledgment = {
+ "coherence": "Warning: The logical flow and organization of this description may be unclear.",
+ "fluency": "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.",
+ "consistency": "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.",
+ "relevance": "Warning: This description may not adequately emphasize the most meteorologically significant patterns.",
+ }
+
+ def run(
+ self,
+ figure: ekp.Figure | None = None,
+ image: ImageFile | None = None,
+ data: ekd.FieldList | None = None,
+ ) -> str:
+ """
+ Run the iterative process of generating and evaluating a weather chart description until quality criteria are met.
+
+ Args:
+ figure (Figure | None): Optional figure to use to generate a description. Can't be used with image.
+ image (ImageFile | None): Optional image to use to generate a description (will be converted to base64). Can't be used with figure.
+ data (FieldList | None): Optional data to use to generate a description.
+
+ Returns:
+ str: The final weather description.
+
+ Raises:
+ RuntimeError: if an error occurs during description generation
+ """
+ if figure is not None and image is not None:
+ raise ValueError(
+ "Only one of 'figure' or 'image' can be provided, not both.",
+ )
+
+ if data is not None and self.data_extractors:
+ logger.info("Enriching prompts with data extractors...")
+ for extractor in self.data_extractors:
+ try:
+ features = extractor.extract(data)
+ features_str = extractor.format_features_to_str(features)
+ self._add_data_features_to_agent_prompt(
+ features_str, agent="generator"
+ )
+ self._add_data_features_to_agent_prompt(
+ features_str, agent="evaluator"
+ )
+ except Exception:
+ continue
+
+ try:
+ description: str | GeneratorOutput = ""
+ evaluation: list[CriterionEvaluatorOutput] = []
+ for i in range(self.max_iterations):
+ logger.info(
+ "Starting orchestration iteration %d/%d", i + 1, self.max_iterations
+ )
+ description = self.generator_agent.generate(
+ figure=figure,
+ image=image,
+ return_intermediate_steps=False,
+ )
+ if not isinstance(description, str):
+ raise TypeError(
+ f"Expected description to be a string, got {type(description)}",
+ )
+
+ if not description:
+ raise ValueError("Generated description is empty.")
+
+ evaluation = self.evaluator_agent.evaluate(
+ description,
+ image=image,
+ figure=figure,
+ )
+
+ if self._verify_evaluation_passes(evaluation):
+ logger.info(
+ "All criteria met, orchestration completed successfully",
+ extra={
+ "description_length": len(description),
+ "iteration": i + 1,
+ "final_scores": {
+ eval_result.name: eval_result.score
+ for eval_result in evaluation
+ },
+ },
+ )
+ return description
+
+ unmet_criteria = [
+ c for c in evaluation if c.score < self.criteria_threshold
+ ]
+ logger.debug(
+ "Providing feedback for iteration %d",
+ i + 1,
+ extra={
+ "iteration": i + 1,
+ "unmet_criteria": [c.name for c in unmet_criteria],
+ "unmet_scores": {c.name: c.score for c in unmet_criteria},
+ },
+ )
+ self._provide_feedback_to_generator(i + 1, description, evaluation)
+
+ logger.info(
+ "Maximum iterations %d reached without passing evaluation. Acknowledging limits of description.",
+ self.max_iterations,
+ )
+
+ if not isinstance(description, str):
+ raise TypeError(
+ f"Expected description to be a string, got {type(description)}",
+ )
+ if not description:
+ raise ValueError("Final generated description is empty.")
+
+ return self._acknowledge_limits_of_description(
+ description,
+ evaluation,
+ )
+ except Exception as e:
+ raise RuntimeError("Failed to generate a description") from e
+
+ def _add_data_features_to_agent_prompt(self, features: str, agent: str) -> None:
+ """Add extracted data features to end of agent prompt
+
+ Args:
+ features (str): extracted and string formatted data features
+ agent (str): agent to add the prompt to
+ """
+ if agent not in ["generator", "evaluator"]:
+ raise ValueError(
+ f"agent parameter should be one of ['generator', 'evaluator'], found {agent}"
+ )
+
+ if agent == "generator":
+ self.generator_agent.append_user_prompt(features)
+
+ self.evaluator_agent.append_user_prompt(features)
+
+ def _verify_evaluation_passes(
+ self,
+ evaluation: list[CriterionEvaluatorOutput],
+ ) -> bool:
+ """
+ Verify if the evaluation passes the quality criteria.
+
+ Args:
+ evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
+
+ Returns:
+ bool: True if evaluation passes, False otherwise
+ """
+ return all(
+ criterion.score >= self.criteria_threshold for criterion in evaluation
+ )
+
+ def _provide_feedback_to_generator(
+ self,
+ evaluation_id: int,
+ description: str,
+ evaluation: list[CriterionEvaluatorOutput],
+ ) -> None:
+ """
+ Provide feedback to the GeneratorAgent based on evaluation results.
+
+ Args:
+ description (str): The description generated by the GeneratorAgent
+ evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
+ """
+ unmet_criteria = [
+ criterion
+ for criterion in evaluation
+ if criterion.score < self.criteria_threshold
+ ]
+ if not unmet_criteria:
+ logger.warning("No unmet criteria found in evaluation.")
+ return
+
+ feedback = self.feedback_template.format(
+ evaluation_id=evaluation_id,
+ criteria_scores="\n- ".join(
+ f"- {criterion.name}: {criterion.score}/5"
+ for criterion in unmet_criteria
+ ),
+ criteria_reasoning="\n".join(
+ f"- {criterion.name}: {criterion.reasoning or 'No reasoning available'}"
+ for criterion in unmet_criteria
+ ),
+ description=description,
+ )
+
+ self.generator_agent.append_user_prompt(feedback)
+
+ def _acknowledge_limits_of_description(
+ self,
+ description: str,
+ evaluation: list[CriterionEvaluatorOutput],
+ ) -> str:
+ """
+ Acknowledge the limits of the generated description based on evaluation results.
+
+ Args:
+ description (str): The description generated by the GeneratorAgent
+ evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
+ Returns:
+ str: The description with acknowledgment of its limits added
+ """
+ acknowledgment = "\n"
+ for criterion in evaluation:
+ if criterion.score < self.criteria_threshold:
+ crit_ackn = self.criteria_limits_acknowledgment.get(criterion.name, "")
+ if crit_ackn:
+ acknowledgment += f"\n{crit_ackn}"
+ return description
diff --git a/src/earth_reach/core/prompts/__init__.py b/src/earth_reach/core/prompts/__init__.py
new file mode 100644
index 0000000..5fedd18
--- /dev/null
+++ b/src/earth_reach/core/prompts/__init__.py
@@ -0,0 +1,6 @@
+"""
+Prompts package.
+
+Contains default prompt templates and configurations for the generator,
+evaluator, and orchestrator components.
+"""
diff --git a/src/earth_reach/core/prompts/evaluator.py b/src/earth_reach/core/prompts/evaluator.py
new file mode 100644
index 0000000..f565513
--- /dev/null
+++ b/src/earth_reach/core/prompts/evaluator.py
@@ -0,0 +1,695 @@
+"""
+Evaluator prompts module.
+
+Contains default prompt templates used by the evaluator component
+to assess the quality of generated weather chart descriptions across multiple criteria.
+"""
+
+DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions
+
+## ROLE AND CONTEXT SETTING
+
+You are a scientific communication specialist evaluating weather chart descriptions for coherence. Your task is to assess how well a meteorological text description maintains logical flow and structural organization, specifically for blind scientists who rely entirely on textual information to understand complex weather patterns.
+
+**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research, teaching, and operational forecasting. Coherence failures directly impair scientific analysis and decision-making capabilities.
+
+## COHERENCE FUNDAMENTALS FOR METEOROLOGICAL TEXT
+
+**Core Definition**: Coherence measures whether information flows logically from broadest relevant context → intermediate patterns → finest relevant details, enabling systematic meteorological analysis without visual reference.
+
+**Scale-Appropriate Hierarchy Principle**:
+- **Global charts**: Global circulation → continental patterns → regional systems
+- **Regional charts**: Synoptic context → regional systems → local weather
+- **Local charts**: Regional context → local systems → specific phenomena
+- **Never force inappropriate scale discussions**
+
+**Essential Components**:
+1. **Context Foundation**: Complete technical specification (domain, variables, ranges, intervals)
+2. **Analytical Building Logic**: Each section builds systematically upon previous information
+3. **Scale-Appropriate Transitions**: Smooth connections between relevant spatial scales
+4. **Process Integration Flow**: Static observations → dynamic interpretations → weather implications
+
+## EVALUATION PROCESS
+
+### Step 1: Information Architecture Assessment (Foundation Analysis)
+**Objective**: Evaluate whether the structural organization enables systematic meteorological analysis
+
+**How to Assess Information Architecture**:
+1. **Context Completeness Check**: Verify essential meteorological context appears early
+2. **Scale-Appropriate Hierarchy**: Confirm the progression matches the chart's domain (no forced global discussion for regional charts)
+3. **Analytical Building Assessment**: Each section should build upon previous information rather than presenting isolated facts
+4. **Priority Sequence Logic**: Most meteorologically significant features should be introduced before secondary patterns
+
+**Scale-Appropriate Examples**:
+- **Global Chart**: "Global circulation patterns → Continental manifestations → Regional weather implications"
+- **European Chart**: "North Atlantic synoptic context → European pressure systems → National weather patterns"
+- **US Regional Chart**: "Continental-scale patterns → Regional systems → State-level conditions"
+
+**Red Flags**: Missing essential context, inappropriate scale forcing, isolated pattern lists, secondary features before primary systems
+
+### Step 2: Multi-Scale Flow Integration (Connection Analysis)
+**Objective**: Assess how well the description connects meteorological patterns across relevant spatial scales
+
+**How to Evaluate Multi-Scale Flow**:
+1. **Scale Transition Logic**: Verify transitions between scales are necessary and logical (not forced)
+2. **Connection Explicitness**: Look for clear linking language that explains relationships between scales
+3. **System Interaction Coherence**: Related meteorological systems should be discussed in logical proximity
+4. **Geographic Progression Logic**: Geographic transitions should follow meteorological or systematic organizational principles
+
+**Connection Quality Assessment**:
+- **Excellent**: "The North Atlantic subtropical high (1024 hPa) extends a ridge across western Europe, promoting subsidence that maintains clear skies and drives radiational cooling to -5°C across the British Isles."
+- **Good**: "High pressure over western Europe maintains clear, cold conditions across the British Isles."
+- **Poor**: "High pressure system present. Cold temperatures in Britain." (no explicit causal connection)
+
+**Geographic Transition Examples**:
+- **Strong**: "Moving eastward, this same high pressure influence extends across Scandinavia..."
+- **Weak**: "In Scandinavia..." (abrupt geographic jump without connection)
+
+### Step 3: Analytical Progression Coherence (Process Integration Assessment)
+**Objective**: Evaluate whether the description transforms static data into dynamic meteorological understanding through logical progression
+
+**How to Assess Analytical Progression**:
+1. **Evidence-to-Interpretation Flow**: Check that meteorological conclusions follow logically from quantitative observations
+2. **Process Integration Sequence**: Verify logical flow from pressure/temperature data → circulation patterns → weather implications
+3. **Theoretical Validation Integration**: Assess whether theoretical frameworks (seasonal expectations, circulation models) are woven naturally into the analytical flow
+4. **Inference Clarity**: Ensure dynamic interpretations are clearly distinguished from static observations
+
+**Analytical Flow Quality Examples**:
+- **Excellent**: "The tight pressure gradient (8 hPa/200 km) between the Icelandic low (988 hPa) and Azores high (1028 hPa) drives strong geostrophic winds (inferred 40+ m/s), consistent with winter North Atlantic circulation patterns, bringing storm conditions to western Scotland."
+- **Adequate**: "Low pressure near Iceland and high pressure near the Azores create strong winds and stormy conditions in Scotland."
+- **Poor**: "Low pressure: 988 hPa. High pressure: 1028 hPa. Windy in Scotland." (no analytical connections)
+
+### Step 4: Accessibility-Optimized Coherence Check (Blind-Scientist Assessment)
+**Objective**: Ensure coherence is specifically optimized for non-visual scientific analysis
+
+**How to Assess Accessibility-Optimized Coherence**:
+1. **Spatial Logic Building**: Information sequence must build spatial understanding systematically without visual cues
+2. **Reference Framework Consistency**: Coordinate systems, directional references, and system names must remain consistent throughout
+3. **Complex Relationship Breakdown**: Multi-system interactions must be explained in digestible, sequential analytical steps
+4. **Independent Verification Capability**: Quantitative precision must enable readers to validate described relationships
+
+**Accessibility Examples**:
+- **Strong**: "The Icelandic low, centered at 65°N, 25°W with 988 hPa central pressure, interacts with the Azores high positioned at 38°N, 25°W at 1028 hPa. This 40 hPa pressure difference across 27° latitude creates..."
+- **Weak**: "The northern low and southern high create pressure differences..." (vague spatial references, no verification capability)
+
+## SCORING FRAMEWORK
+
+**Score 5 - Exceptional Coherence**
+- Perfect scale-appropriate information hierarchy that matches chart domain
+- Flawless analytical progression from static data → dynamic interpretations → weather implications
+- Seamless multi-scale connections with explicit linking language throughout
+- Complete accessibility optimization with consistent reference frameworks
+- Structure actively enables systematic meteorological analysis equivalent to visual inspection
+
+**Score 4 - Strong Coherence**
+- Clear scale-appropriate progression with minor structural imperfections
+- Strong analytical flow with occasional gaps in evidence-to-interpretation logic
+- Most multi-scale transitions smooth and meteorologically logical
+- Well-adapted for accessibility with minor reference inconsistencies
+- Structure effectively supports scientific analysis with minimal navigation challenges
+
+**Score 3 - Adequate Coherence**
+- Generally appropriate scale hierarchy with some forced or missing transitions
+- Adequate analytical progression but noticeable gaps in process integration
+- Some scale connections unclear or missing, requiring reader inference
+- Basic accessibility adaptation but some spatial relationships unclear
+- Structure functional for scientific understanding but requires extra interpretive effort
+
+**Score 2 - Poor Coherence**
+- Inappropriate scale forcing or significant hierarchy problems
+- Weak analytical progression with frequent gaps between observations and interpretations
+- Poor multi-scale integration with abrupt transitions or missing connections
+- Accessibility compromised by inconsistent references and unclear spatial logic
+- Structure creates barriers to systematic meteorological analysis
+
+**Score 1 - Very Poor Coherence**
+- Major scale inappropriate discussions or completely illogical information hierarchy
+- Minimal analytical progression with static data poorly connected to dynamic interpretations
+- Severely fragmented multi-scale logic with random or missing transitions
+- Poor accessibility with inconsistent reference frameworks throughout
+- Structure significantly impairs scientific understanding and analysis capability
+
+**Score 0 - Incoherent**
+- No logical organizational structure present
+- Complete absence of analytical progression or process integration
+- Unintelligible scale relationships and system connections
+- Completely inaccessible for non-visual analysis
+- Structure makes meteorological analysis impossible
+
+## CRITICAL EVALUATION STANDARDS
+
+**Pass-Fail Thresholds** (Automatic scoring guidance):
+- **Missing essential context** (domain, variables, ranges, intervals): Maximum score 2
+- **Inappropriate scale forcing** (global discussion for regional charts): Maximum score 2
+- **No multi-scale connections**: Maximum score 3
+- **Poor accessibility optimization** (inconsistent references, unclear spatial logic): Maximum score 3
+
+**Excellence Indicators** (Score 4-5 requirements):
+- **Scale-appropriate hierarchy** perfectly matched to chart domain
+- **Explicit connection language** throughout multi-scale transitions
+- **Systematic analytical progression** from observations to interpretations to implications
+- **Complete accessibility optimization** with consistent, precise reference frameworks
+
+## COMMON PITFALLS TO AVOID
+
+1. **Scale Template Bias**: Don't force global→regional→local for non-global charts - assess scale-appropriateness
+2. **Visual Assumption Penalties**: Heavily penalize descriptions assuming visual understanding ("as shown," "visible")
+3. **Academic Complexity Confusion**: Don't confuse necessary meteorological sophistication with poor coherence
+4. **Connection Quality Misjudgment**: Distinguish between explicit linking language and mere topic adjacency
+5. **Accessibility Standards**: Remember blind scientists need explicit spatial references and consistent frameworks
+
+## OUTPUT REQUIREMENTS
+
+Provide your evaluation in the following XML format:
+
+```xml
+[Your detailed analysis explaining the score, referencing specific aspects of information flow, structural organization, and accessibility-adapted coherence. Include concrete examples from the description to support your assessment.]
+[0-5]
+```
+
+**Critical Requirements**:
+- Score must be an integer from 0 to 5
+- Reasoning should reference specific textual evidence
+- All XML tags must be properly closed
+- No additional formatting or text outside the XML structure
+
+**Success Check**: Your evaluation should enable a developer to understand exactly what coherence strengths or weaknesses exist in the description and how to improve them.
+"""
+
+DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Fluency" Quality Criteria Evaluation Prompt
+
+## ROLE AND CONTEXT SETTING
+
+You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, meteorological terminology accuracy, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns.
+
+**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Grammatical errors, incorrect meteorological terminology, or unclear expression can render precise meteorological data unusable for research and operational decisions.
+
+## FLUENCY STANDARDS FOR METEOROLOGICAL TEXT
+
+**Core Components**:
+1. **Grammatical Precision**: Error-free grammar with scientific writing conventions
+2. **Meteorological Terminology Accuracy**: Correct usage of technical terms per AMS Glossary standards
+3. **Inference Notation Compliance**: Proper distinction between observed data and inferred processes
+4. **Accessibility Language Standards**: No assumptive visual language that excludes blind users
+5. **Professional Scientific Voice**: Consistent, objective tone appropriate for research use
+
+**Critical Language Requirements**:
+- **Prohibited Assumptive Visual Language**: Phrases that assume the reader has visual access: "as you can see," "if you look at," "clearly visible to the viewer," "obviously shown in the image"
+- **Acceptable Descriptive Visual Language**: Objective descriptions of visual elements (colors, patterns, chart features) that help blind users understand the complete picture
+- **Required Inference Notation**: All dynamic interpretations must be marked (e.g., "inferred from pressure gradients")
+- **Mandatory Unit Consistency**: All meteorological values must include consistent, appropriate units
+
+## EVALUATION PROCESS
+
+### Step 1: Grammar and Scientific Writing Standards (Foundation Analysis)
+**Objective**: Examine grammatical correctness and adherence to scientific writing conventions
+
+**How to Assess Grammar and Writing Standards**:
+1. **Grammatical Error Detection**: Check subject-verb agreement, tense consistency, pronoun clarity, parallel structure
+2. **Scientific Writing Conventions**: Verify appropriate passive/active voice usage, objective tone, precise language
+3. **Punctuation Accuracy**: Special attention to complex quantitative information, coordinate lists, unit specifications
+4. **Sentence Structure Variety**: Assess appropriate variation in structure for readability without sacrificing precision
+
+**Grammar Quality Examples**:
+- **Excellent**: "The Icelandic low (988 hPa) creates steep pressure gradients across 200 km, driving winds (inferred) exceeding 25 m/s through geostrophic balance."
+- **Poor**: "Looking at the chart, you can clearly see that there's a low pressure system that's obviously creating some pretty strong winds over there." (visual references, imprecise language, exceeds word limit)
+
+### Step 2: Meteorological Terminology and Unit Accuracy (Precision Analysis)
+**Objective**: Evaluate accuracy and consistency of meteorological language and quantitative specifications
+
+**How to Assess Terminology and Unit Accuracy**:
+1. **AMS Glossary Compliance**: Verify meteorological terms used correctly per American Meteorological Society standards
+2. **Unit Consistency and Appropriateness**: Check all values include proper units (hPa, °C, m/s, km) used consistently throughout
+3. **Coordinate Precision Standards**: Assess latitude/longitude references for proper format and precision
+4. **Technical Vocabulary Appropriateness**: Evaluate terminology level suitable for PhD-level meteorologists
+5. **Quantitative Integration**: Ensure numerical values and units integrate smoothly within sentence structure
+
+**Terminology Accuracy Examples**:
+- **Correct**: "anticyclonic circulation," "geostrophic wind," "baroclinic zone," "subsidence inversion"
+- **Incorrect**: "cyclonic high pressure" (contradictory), "windspeed" (should be two words), "temperature gradient" without quantification
+
+### Step 3: Inference Notation Compliance (Scientific Rigor Analysis)
+**Objective**: Evaluate proper distinction between observed data and inferred processes
+
+**How to Assess Inference Notation**:
+1. **Dynamic Process Marking**: Verify all interpreted movements, winds, and weather processes are marked as inferences
+2. **Observation vs. Interpretation Clarity**: Check clear distinction between measured data and meteorological interpretations
+3. **Inference Marking Consistency**: Ensure inference notation applied uniformly throughout description
+4. **Appropriate Inference Language**: Verify use of proper qualifying terms (inferred, likely, estimated, suggested by)
+
+**Inference Notation Examples**:
+- **Correct**: "Strong westerly winds (inferred from 8 hPa/200 km pressure gradient) likely exceed 30 m/s."
+- **Correct**: "The circulation pattern, inferred from isobar configuration, suggests cyclonic rotation."
+- **Incorrect**: "Strong westerly winds exceed 30 m/s across the region." (unmarked dynamic interpretation)
+- **Incorrect**: "The low pressure system is moving eastward." (unmarked inference about movement)
+
+### Step 4: Accessibility Language Standards (Inclusivity Analysis)
+**Objective**: Evaluate absence of assumptive visual language and quality of spatial descriptions
+
+**How to Assess Accessibility Language**:
+1. **Assumptive Language Detection**: Identify phrases that assume visual access to the chart
+2. **Spatial Reference Quality**: Verify spatial relationships use explicit coordinates/directions
+3. **Visual Element Description**: Ensure objective description of colors, patterns when mentioned
+4. **Navigation Independence**: Check that description doesn't require visual navigation
+
+**Assumptive Visual Language Examples**:
+- **Unacceptable**: "As you can see in the chart," "clearly visible," "if you look at," "obviously shown"
+- **Acceptable**: "The chart displays," "The data indicates," "Located at 50°N"
+
+**Visual Element Description Examples**:
+- **Good**: "This weather chart displays temperatures using colors from blue (-30°C) to orange (20°C)"
+- **Poor**: "As you can see, the blue areas are cold" (assumes visual access)
+- **Good**: "The low pressure center, positioned at 55°N, 15°W, creates circulation patterns..."
+- **Poor**: "The low shown in the upper left creates obvious circulation patterns..." (spatial assumption)
+
+### Step 5: Professional Scientific Voice and Consistency (Style Analysis)
+**Objective**: Evaluate maintenance of appropriate scientific tone and consistent professional voice
+
+**How to Assess Scientific Voice and Consistency**:
+1. **Tone Consistency Assessment**: Verify objective, professional tone maintained throughout description
+2. **Voice Perspective Stability**: Check for consistent third-person perspective without inappropriate shifts
+3. **Scientific Objectivity**: Ensure language maintains appropriate distance between observations and interpretations
+4. **Professional Appropriateness**: Assess language choices suitable for peer-reviewed scientific context
+5. **Credibility Maintenance**: Verify writing style supports scientific authority and research applicability
+
+**Scientific Voice Examples**:
+- **Professional**: "The pressure gradient analysis indicates geostrophic wind speeds (inferred) approaching 35 m/s."
+- **Unprofessional**: "I can see that the winds are really strong here, probably around 35 m/s." (first person, informal tone, visual reference)
+
+## SCORING FRAMEWORK
+
+**Score 5 - Exceptional Fluency**
+- Flawless grammar with perfect adherence to scientific writing conventions and 25-word sentence limits
+- Perfect meteorological terminology per AMS Glossary standards with complete unit consistency
+- All inferred processes properly marked with consistent inference notation
+- Complete absence of assumptive visual language with clear spatial descriptions
+- Exemplary professional scientific voice maintaining perfect objectivity and credibility
+
+**Score 4 - Strong Fluency**
+- Minor grammatical issues that don't impair understanding, good sentence length control
+- Correct meteorological terminology with minor unit inconsistencies
+- Most inferences properly marked with good observation/interpretation distinction
+- Minimal assumptive language with generally accessible descriptions
+- Professional scientific tone with minor voice consistency issues
+
+**Score 3 - Adequate Fluency**
+- Some grammatical errors that require extra effort to understand
+- Generally correct terminology with noticeable unit inconsistencies or minor term misusage
+- Adequate inference marking but some unmarked interpretations
+- Occasional assumptive visual language or unclear spatial references
+- Scientific tone maintained with occasional unprofessional lapses or voice shifts
+
+**Score 2 - Poor Fluency**
+- Frequent grammatical errors, sentence length violations, or awkward constructions that impede comprehension
+- Significant meteorological terminology errors or widespread unit inconsistencies
+- Poor inference marking with many unmarked dynamic processes
+- Frequent assumptive visual language compromising accessibility
+- Inconsistent scientific tone with frequent unprofessional expressions
+
+**Score 1 - Very Poor Fluency**
+- Major grammatical problems throughout with extensive sentence length violations
+- Substantial meteorological terminology errors that compromise scientific accuracy
+- Minimal inference marking throughout description
+- Pervasive assumptive visual language making text inaccessible
+- Unprofessional tone with frequent voice inconsistencies and credibility issues
+
+**Score 0 - No Fluency**
+- Extensive grammatical errors making text barely comprehensible
+- Incorrect meteorological terminology throughout undermining scientific validity
+- Complete absence of inference marking for dynamic processes
+- Pervasive visual assumptions making description unusable for blind users
+- Entirely inappropriate scientific voice destroying credibility
+
+## OUTPUT REQUIREMENTS
+
+Provide your evaluation in the following XML format:
+
+```xml
+[Your detailed analysis explaining the score, referencing specific examples of grammatical correctness, terminology usage, readability, and scientific voice. Include concrete textual evidence to support your assessment.]
+[0-5]
+```
+
+**Critical Requirements**:
+- Score must be an integer from 0 to 5
+- Reasoning should reference specific linguistic evidence from the description
+- All XML tags must be properly closed
+- No additional formatting or text outside the XML structure
+
+**Success Check**: Your evaluation should enable a developer to understand exactly what linguistic strengths or weaknesses exist in the description and provide actionable guidance for improvement.
+"""
+
+DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Consistency" Criteria Evaluation Prompt
+
+## ROLE AND CONTEXT SETTING
+
+You are a meteorological data validation specialist evaluating weather chart descriptions for factual accuracy and internal consistency. Your task is to assess how well a text description aligns with its source weather chart and whether all described elements are logically consistent with each other and with meteorological principles.
+
+**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research and operations. Consistency errors can lead to incorrect scientific conclusions, flawed forecasts, and compromised safety decisions in weather-sensitive operations.
+
+## CONSISTENCY STANDARDS FOR METEOROLOGICAL VALIDATION
+
+**Core Definition**: Consistency measures factual accuracy between chart and description, internal logical coherence, and meteorological plausibility according to atmospheric physics and climatological expectations.
+
+**Critical Accuracy Thresholds**:
+- **Spatial Accuracy Standard**: All pressure center locations within ±2° tolerance of actual chart positions
+- **Domain Completeness**: Full coverage verification (no truncation like 60°N-60°S when global coverage exists)
+- **Quantitative Precision**: All values within measurement resolution limits of source chart data
+- **Theoretical Consistency**: All patterns validated against seasonal expectations and circulation models
+
+**ZERO TOLERANCE**: Spatial misplacements render descriptions worse than useless - they become actively misleading.
+
+## EVALUATION PROCESS
+
+### Step 1: Mandatory Spatial Accuracy Verification (Critical Foundation Assessment)
+**Objective**: Verify all spatial information within strict accuracy tolerances to prevent misleading descriptions
+
+**How to Verify Spatial Accuracy**:
+1. **Pressure Center Location Verification**: Compare each described pressure system location against actual chart position (±2° maximum tolerance)
+2. **Domain Boundary Verification**: Confirm complete domain coverage matches chart extent (no artificial truncations)
+3. **Geographic Reference Validation**: Check all coordinate references against actual chart features using coastlines/continents as anchor points
+4. **Relative Position Consistency**: Verify all described spatial relationships (north/south/east/west) match actual chart positions
+5. **System Extent Accuracy**: Ensure described pressure system sizes and coverage areas match chart representations
+
+**Spatial Accuracy Examples**:
+- **Accurate**: "High pressure center at 45°N, 15°E over the Alps" (when chart shows center at 44°N, 16°E)
+- **FAILED**: "High pressure center over the North Sea" (when chart shows center over Scandinavia - >200 km error)
+
+**Red Flags**: Any pressure center >2° from actual position, domain truncation, geographic anchor misplacement
+
+### Step 2: Theoretical and Seasonal Consistency Validation (Scientific Plausibility Assessment)
+**Objective**: Ensure all described patterns align with meteorological theory and seasonal expectations
+
+**How to Assess Theoretical Consistency**:
+1. **Seasonal Pattern Validation**: Check if described patterns match climatological expectations for the given date/location
+2. **Circulation Model Consistency**: Verify patterns align with three-cell circulation model, jet stream positions, typical pressure system locations
+3. **Physical Process Verification**: Ensure temperature-pressure relationships follow atmospheric physics principles
+4. **Gradient Plausibility**: Confirm pressure gradients and system intensities are meteorologically realistic
+5. **System Interaction Logic**: Validate that described system interactions follow known atmospheric dynamics
+
+**Theoretical Consistency Examples**:
+- **Consistent**: "1048 hPa Siberian high in February promotes continental cold air mass" (seasonally appropriate)
+- **Inconsistent**: "1048 hPa Siberian high in July" (climatologically implausible intensity/timing)
+
+### Step 3: Multi-Scale Internal Consistency Verification (Logical Coherence Assessment)
+**Objective**: Assess whether all described elements align logically across spatial scales and within the description framework
+
+**How to Assess Multi-Scale Consistency**:
+1. **Cross-Scale Logical Verification**: Ensure local features are consistent with regional patterns, which are consistent with broader atmospheric context
+2. **Spatial Relationship Consistency**: Verify all described spatial relationships are internally coherent (if A is north of B, coordinates must reflect this)
+3. **Quantitative Cross-Verification**: Check that different quantitative references support each other rather than contradict
+4. **System Interaction Consistency**: Ensure described system interactions are logical across all mentioned scales
+5. **Reference Framework Consistency**: Verify coordinate systems, units, and measurement frameworks remain consistent throughout
+
+**Multi-Scale Consistency Examples**:
+- **Consistent**: "North Atlantic high (1028 hPa) extends ridge over western Europe, promoting subsidence and clear skies across Britain"
+- **Inconsistent**: "High pressure over Britain promotes storms" (contradictory meteorological relationship)
+
+### Step 4: Quantitative Precision and Chart Fidelity Assessment (Data Accuracy Analysis)
+**Objective**: Evaluate numerical accuracy and measurement consistency against source chart capabilities
+
+**How to Assess Quantitative Precision**:
+1. **Chart Resolution Compliance**: Ensure claimed precision doesn't exceed source chart measurement capabilities
+2. **Value Range Verification**: Confirm all described ranges accurately reflect chart data distribution
+3. **Unit Accuracy and Consistency**: Verify all quantitative values include correct, consistent units throughout
+4. **Measurement Interval Consistency**: Check that described measurement intervals match chart specifications
+5. **Extreme Value Validation**: Ensure claimed extreme values are actually visible/readable from the source chart
+
+**Quantitative Precision Examples**:
+- **Accurate**: "Pressure contours at 4 hPa intervals from 1004 to 1032 hPa" (matches chart contour labeling)
+- **Inaccurate**: "Pressure varies continuously from 1004.3 to 1031.7 hPa" (false precision beyond chart resolution)
+
+## SCORING FRAMEWORK
+
+**Score 5 - Perfect Consistency**
+- ALL pressure centers within ±2° tolerance with perfect spatial accuracy throughout
+- Complete theoretical consistency with seasonal expectations and circulation models
+- Flawless multi-scale internal logic with no contradictions across any spatial scales
+- Perfect quantitative precision matching chart resolution capabilities exactly
+- Complete domain coverage accuracy with no truncations or omissions
+
+**Score 4 - Strong Consistency**
+- ALL pressure centers within ±2° tolerance with minor spatial reference inconsistencies elsewhere
+- Strong theoretical consistency with minor seasonal or circulation model deviations
+- Generally strong multi-scale logic with occasional minor internal contradictions
+- High quantitative accuracy with minor unit inconsistencies or precision issues
+- Complete domain coverage with minor boundary specification issues
+
+**Score 3 - Adequate Consistency**
+- MOST pressure centers within ±2° tolerance but some minor spatial accuracy issues
+- Generally appropriate theoretical patterns with some seasonal or theoretical inconsistencies
+- Adequate multi-scale consistency but noticeable internal contradictions requiring clarification
+- Generally accurate quantitative data with some precision or unit issues
+- Adequate domain coverage but some specification problems
+
+**Score 2 - Poor Consistency**
+- SOME pressure centers exceed ±2° tolerance or significant spatial accuracy problems
+- Poor theoretical consistency with multiple seasonal or circulation model violations
+- Significant multi-scale contradictions affecting scientific interpretation
+- Quantitative errors that could mislead analysis with widespread unit or precision problems
+- Incomplete or inaccurate domain coverage affecting interpretation
+
+**Score 1 - Very Poor Consistency**
+- MOST pressure centers exceed ±2° tolerance with major spatial misplacements throughout
+- Major theoretical inconsistencies violating seasonal patterns and circulation principles
+- Extensive multi-scale contradictions severely undermining description credibility
+- Substantial quantitative errors affecting interpretation with pervasive precision/unit problems
+- Major domain coverage errors or truncations affecting scientific utility
+
+**Score 0 - No Consistency**
+- ALL or most pressure centers grossly misplaced (>5° errors) making description actively misleading
+- Complete theoretical implausibility violating basic atmospheric physics
+- Pervasive contradictions making description scientifically unusable
+- Quantitative data largely incorrect or nonsensical throughout
+- Domain coverage completely inaccurate or missing
+
+## CRITICAL EVALUATION STANDARDS
+
+**MANDATORY PASS-FAIL THRESHOLDS**:
+- **ANY pressure center >2° from actual position**: Maximum score 1 (description becomes actively misleading)
+- **Domain truncation when full coverage exists**: Maximum score 2 (incomplete scientific information)
+- **Major theoretical violations** (e.g., impossible seasonal patterns): Maximum score 2
+- **Extensive quantitative errors** beyond chart resolution: Maximum score 2
+
+**Excellence Requirements** (Score 4-5):
+- **Perfect spatial accuracy**: ALL pressure centers within ±2° tolerance
+- **Complete theoretical consistency**: Seasonal and circulation model alignment
+- **Multi-scale coherence**: No internal contradictions across spatial scales
+- **Quantitative precision**: Accuracy matching chart resolution capabilities
+
+## COMMON PITFALLS TO AVOID
+
+1. **Spatial Accuracy Tolerance**: ZERO tolerance for >2° pressure center errors - these make descriptions actively misleading
+2. **Theoretical Complexity Confusion**: Don't excuse clear seasonal/circulation violations as "atmospheric complexity"
+3. **Chart Resolution Expectations**: Don't demand precision beyond chart capabilities, but verify claimed precision is achievable
+4. **Multi-Scale Logic**: Ensure local descriptions are consistent with regional and broader patterns mentioned
+5. **Domain Coverage Standards**: Verify complete coverage matches chart extent - no artificial truncations acceptable
+
+## OUTPUT REQUIREMENTS
+
+Provide your evaluation in the following XML format:
+
+```xml
+[Your detailed analysis explaining the score, referencing specific examples of source-description alignment, internal consistency, meteorological plausibility, and quantitative accuracy. Include concrete evidence from both the chart and description to support your assessment.]
+[0-5]
+```
+
+**Critical Requirements**:
+- Score must be an integer from 0 to 5
+- Reasoning should reference specific examples comparing chart features to description elements
+- All XML tags must be properly closed
+- No additional formatting or text outside the XML structure
+
+**Success Check**: Your evaluation should enable a developer to understand exactly what consistency strengths or weaknesses exist between the source chart and description, and provide actionable guidance for improving accuracy.
+"""
+
+DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Relevance" Criteria Evaluation Prompt
+
+## ROLE AND CONTEXT SETTING
+
+You are a meteorological analysis expert evaluating weather chart descriptions for scientific relevance and information prioritization. Your task is to assess whether a text description captures and emphasizes the most meteorologically significant patterns from the source weather chart, enabling blind scientists to conduct the same quality analysis as their sighted colleagues.
+
+**Critical Context**: These descriptions must distill complex weather charts into the most scientifically valuable information within strict word limits. Poor relevance assessment can render descriptions analytically useless, forcing blind meteorologists to miss critical weather patterns or waste time on insignificant details.
+
+## RELEVANCE STANDARDS FOR METEOROLOGICAL ANALYSIS
+
+**Core Definition**: Relevance measures whether descriptions prioritize meteorologically significant patterns and enable expert-level analytical conclusions equivalent to visual chart inspection.
+
+**Expert-Level Requirements** (based on professional meteorological analysis):
+- **Multi-Scale Integration Priority**: Most important patterns emphasized across global/regional/local scales as appropriate
+- **Dynamic Process Emphasis**: Static data transformed into circulation patterns and weather implications
+- **Theoretical Framework Priority**: Seasonal validation and circulation model context for significant patterns
+- **Analytical Enablement**: Sufficient information for forecast reasoning, process understanding, and research decisions
+
+**Information Priority Hierarchy**:
+1. **Most Intense Systems**: Strongest pressure centers and steepest gradients receive primary emphasis
+2. **Meteorologically Significant Patterns**: Unusual, extreme, or climatologically important features highlighted
+3. **Multi-Scale Context**: Pattern significance established through appropriate scale connections
+4. **Dynamic Implications**: Weather and circulation consequences of observed patterns
+
+## EVALUATION PROCESS
+
+### Step 1: Meteorological Significance Prioritization Assessment (Primary Pattern Analysis)
+**Objective**: Evaluate whether the most meteorologically significant systems receive appropriate emphasis and early attention
+
+**How to Assess Meteorological Significance Prioritization**:
+1. **System Intensity Ranking**: Verify strongest pressure systems (highest/lowest values) receive primary emphasis
+2. **Gradient Significance Assessment**: Check that steepest pressure gradients and strongest temperature contrasts are highlighted early
+3. **Climatological Importance Evaluation**: Assess whether unusual, extreme, or seasonally significant patterns receive appropriate attention
+4. **Early Emphasis Verification**: Confirm most significant patterns appear early in description rather than buried in secondary details
+5. **Comparative Intensity Assessment**: Ensure system strength rankings in text match actual meteorological intensity
+
+**Significance Prioritization Examples**:
+- **Excellent**: "The exceptional 1052 hPa high pressure system dominates northern Europe, representing extreme subsidence..." (leads with most intense feature)
+- **Poor**: "Various pressure systems exist across Europe, including a 1052 hPa high..." (buries extreme intensity in generic statement)
+
+### Step 2: Multi-Scale Integration and Dynamic Process Priority (Analytical Depth Assessment)
+**Objective**: Assess whether descriptions prioritize multi-scale connections and dynamic process understanding over static data reporting
+
+**How to Assess Multi-Scale Integration and Dynamic Process Priority**:
+1. **Multi-Scale Connection Emphasis**: Verify that scale connections receive adequate emphasis rather than being treated as afterthoughts
+2. **Dynamic Process Integration Priority**: Check that circulation patterns and weather implications receive prominent attention
+3. **Static-to-Dynamic Transformation**: Assess whether static pressure/temperature data is systematically transformed into process understanding
+4. **Process Mechanism Explanation**: Evaluate whether physical mechanisms behind observed patterns receive appropriate attention
+5. **Weather Implication Priority**: Verify that weather consequences of pressure systems receive adequate emphasis
+
+**Dynamic Process Priority Examples**:
+- **Excellent**: "The 1028 hPa high drives anticyclonic circulation, promoting subsidence and clear skies (inferred) across western Europe, while creating strong pressure gradients..."
+- **Adequate**: "High pressure (1028 hPa) over western Europe creates clear conditions..."
+- **Poor**: "Pressure values: 1028 hPa high, 1012 hPa low, 1020 hPa ridge..." (static data listing without process integration)
+
+### Step 3: Expert-Level Analytical Enablement Assessment (Research Utility Analysis)
+**Objective**: Determine whether descriptions enable the same analytical conclusions and research capabilities as expert meteorological analysis
+
+**How to Assess Expert-Level Analytical Enablement**:
+1. **Forecast Reasoning Capability**: Assess whether description provides sufficient information for weather prediction and forecast reasoning
+2. **Process Understanding Support**: Evaluate if description enables understanding of atmospheric processes and physical mechanisms
+3. **Research Decision Support**: Determine whether operational or research decisions could be made based on the provided information
+4. **Comparative Analysis Capability**: Check if description enables comparison with climatological patterns and seasonal expectations
+5. **Independent Validation Potential**: Assess whether readers can independently verify and extend the analytical conclusions
+
+**Analytical Enablement Examples**:
+- **Expert-Level**: "The 1052 hPa Scandinavian high, exceptional for February, promotes continental cold air advection through geostrophic balance, creating temperature gradients of 15°C/500 km across central Europe, indicating strong frontal potential..."
+- **Limited**: "High pressure over Scandinavia brings cold weather to Europe..."
+- **Insufficient**: "Cold temperatures across Europe..." (no analytical framework provided)
+
+### Step 4: Information Efficiency and Contextual Appropriateness (Optimization Analysis)
+**Objective**: Evaluate whether word limit usage maximizes meteorological value and matches chart scale/context appropriately
+
+**How to Assess Information Efficiency and Contextual Appropriateness**:
+1. **Word Limit Optimization**: Verify that every significant meteorological detail serves analytical purposes rather than filling space
+2. **Scale-Appropriate Detail Level**: Assess whether detail level matches chart scale (global vs regional vs local analysis priorities)
+3. **Seasonal/Geographic Context Alignment**: Check that emphasis matches regional and seasonal meteorological priorities
+4. **Analytical Value Density**: Evaluate whether included details directly support meteorological conclusions and understanding
+5. **Context-Specific Priority Matching**: Verify that emphasized patterns match typical meteorological analysis priorities for the given domain/season
+
+**Information Efficiency Examples**:
+- **Efficient**: "The 1048 hPa Siberian high, intense for early March, drives continental outflow affecting European temperatures by 10-15°C below normal..."
+- **Inefficient**: "The Siberian high pressure system has a central pressure of 1048 hPa and covers a large area of Siberia with generally high pressure conditions..."
+- **Inappropriate Scale**: Discussing global circulation for a European regional chart when European synoptic patterns should dominate
+
+## SCORING FRAMEWORK
+
+**Score 5 - Exceptional Relevance**
+- Perfect prioritization of strongest systems and most significant patterns with early emphasis
+- Exceptional multi-scale integration and dynamic process emphasis throughout description
+- Expert-level analytical enablement supporting forecast reasoning, process understanding, and research decisions
+- Optimal information efficiency maximizing meteorological value with perfect scale/context appropriateness
+- Every detail directly supports primary meteorological conclusions and enables equivalent analysis to visual inspection
+
+**Score 4 - Strong Relevance**
+- Strong prioritization of significant systems with most important patterns emphasized early
+- Good multi-scale integration and dynamic process emphasis with minor static data focus
+- High-quality analytical enablement supporting most forecast and research needs with minor limitations
+- Good information efficiency with occasional less critical details but generally appropriate scale/context matching
+- Most details effectively support meteorological conclusions and analytical capabilities
+
+**Score 3 - Adequate Relevance**
+- Generally appropriate prioritization but some significant patterns under-emphasized or buried
+- Adequate multi-scale integration but noticeable emphasis on static data over dynamic processes
+- Basic analytical enablement supporting fundamental meteorological analysis but missing some research opportunities
+- Reasonable information efficiency but some space wasted on less critical details with occasional scale/context mismatches
+- Mix of relevant and less relevant details affecting overall analytical efficiency
+
+**Score 2 - Poor Relevance**
+- Poor prioritization with important systems under-emphasized and weak systems over-emphasized
+- Limited multi-scale integration with heavy focus on static data reporting over process understanding
+- Limited analytical enablement providing insufficient information for forecast reasoning or research analysis
+- Poor information efficiency with significant space devoted to minor details and poor scale/context matching
+- Many included details don't support primary meteorological understanding or analytical conclusions
+
+**Score 1 - Very Poor Relevance**
+- Major meteorological features largely ignored with inappropriate emphasis on minor systems
+- Minimal multi-scale integration with predominantly static data listing and little process understanding
+- Severely limited analytical enablement preventing quality meteorological analysis and research application
+- Very poor information efficiency focusing on insignificant details with inappropriate scale/context priorities
+- Most included details irrelevant to meteorological analysis needs and conclusions
+
+**Score 0 - No Relevance**
+- Complete failure to identify or appropriately emphasize any significant meteorological patterns
+- No multi-scale integration or dynamic process emphasis - purely static data reporting
+- No analytical enablement capability - description provides no useful meteorological analysis support
+- Information completely unfocused with no appropriate meteorological priorities or context consideration
+- Content largely irrelevant to meteorological analysis needs with no scientific value
+
+## CRITICAL EVALUATION STANDARDS
+
+**Pass-Fail Thresholds** (Automatic scoring guidance):
+- **Strongest systems not emphasized early**: Maximum score 2 (defeats primary purpose)
+- **No dynamic process integration**: Maximum score 2 (eliminates analytical value)
+- **Insufficient detail for basic forecast reasoning**: Maximum score 2 (fails analytical enablement)
+- **Inappropriate scale emphasis**: Maximum score 3 (mismatched context priorities)
+
+**Excellence Indicators** (Score 4-5 requirements):
+- **Perfect intensity-based prioritization**: Strongest systems receive primary emphasis early
+- **Systematic dynamic process integration**: Static data transformed into circulation and weather understanding
+- **Expert-level analytical enablement**: Sufficient detail for forecast reasoning and research decisions
+- **Optimal information efficiency**: Every significant detail supports meteorological conclusions
+
+## COMMON PITFALLS TO AVOID
+
+1. **Intensity Ranking Errors**: Don't accept weak system emphasis over strong systems - intensity determines meteorological significance
+2. **Static Data Tolerance**: Penalize pure data reporting without process integration - descriptions must enable dynamic understanding
+3. **Completeness vs. Relevance Confusion**: Focus on meteorological significance, not comprehensive coverage of all features
+4. **Scale Appropriateness**: Assess whether emphasis matches chart scale - don't accept global detail for regional charts or vice versa
+5. **Analytical Utility Standards**: Verify descriptions enable the same conclusions as expert meteorological analysis
+
+## OUTPUT REQUIREMENTS
+
+Provide your evaluation in the following XML format:
+
+```xml
+[Your detailed analysis explaining the score, referencing specific examples of meteorological significance prioritization, information density optimization, analytical enablement, and contextual appropriateness. Include concrete evidence of what important information is emphasized or missed.]
+[0-5]
+```
+
+**Critical Requirements**:
+- Score must be an integer from 0 to 5
+- Reasoning should reference specific examples of information prioritization and meteorological significance
+- All XML tags must be properly closed
+- No additional formatting or text outside the XML structure
+
+**Success Check**: Your evaluation should enable a developer to understand exactly what meteorological information priorities are appropriate and how well the description serves analytical needs for blind scientists.
+"""
+
+
+def get_default_criterion_evaluator_user_prompt(criterion: str) -> str:
+ """
+ Get the default CriteriaEvaluatorAgent user prompt for the specified criterion.
+
+ Args:
+ criterion (str): The criterion for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance.
+
+ Returns:
+ str: The default criterion user prompt text.
+ """
+ if criterion == "coherence":
+ return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT
+ if criterion == "fluency":
+ return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT
+ if criterion == "consistency":
+ return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT
+ if criterion == "relevance":
+ return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT
+ raise ValueError(
+ f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance.",
+ )
diff --git a/src/earth_reach/core/prompts/generator.py b/src/earth_reach/core/prompts/generator.py
new file mode 100644
index 0000000..df0802b
--- /dev/null
+++ b/src/earth_reach/core/prompts/generator.py
@@ -0,0 +1,387 @@
+"""
+Generator prompts module.
+
+Contains default prompt templates used by the generator component
+to create detailed weather chart descriptions from meteorological visualizations.
+"""
+
+DEFAULT_GENERATOR_USER_PROMPT = """# Weather Chart Alt-Text Generation System
+
+## ROLE AND CONTEXT SETTING
+
+You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible.
+
+**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues.
+
+**Critical Context**: Your descriptions will be used for:
+- Research analysis and data interpretation
+- Scientific paper writing and peer review
+- Teaching and educational materials
+- Operational weather forecasting decisions
+
+## METEOROLOGICAL REFERENCE GUIDE
+
+### Core Atmospheric Circulation Patterns
+- **Three-Cell Model**:
+ - Hadley Cell (0-30°): Rising air at equator, sinking at subtropics
+ - Ferrel Cell (30-60°): Surface westerlies, opposite of Hadley
+ - Polar Cell (60-90°): Cold sinking air at poles, surface easterlies
+
+### Seasonal Pattern Expectations
+- **Boreal Winter (DJF)**: Strong Aleutian/Icelandic lows, intense Siberian high, equatorward-shifted jet streams
+- **Boreal Summer (JJA)**: Weakened polar lows, strengthened/northward subtropical highs, monsoon patterns
+- **Transition Seasons**: Rapid pattern changes, increased variability
+
+### Pressure-Weather Relationships
+- **High Pressure**: Subsidence → clear skies, light winds, stable conditions
+- **Low Pressure**: Convergence → clouds, precipitation, stronger winds
+- **Pressure Gradients**: Tight spacing = strong winds (>5 hPa/100km = significant)
+
+### Extreme Value Thresholds
+- **Exceptional High**: >1040 hPa (especially >1050 hPa)
+- **Deep Low**: <980 hPa (hurricane-strength if <960 hPa)
+- **Strong Temperature Gradient**: >10°C/500 km (likely frontal zone)
+
+### Geostrophic Wind Principles
+- **Northern Hemisphere**: Wind flows parallel to isobars, low pressure to the left
+- **Southern Hemisphere**: Wind flows parallel to isobars, low pressure to the right
+- **Wind Speed**: Proportional to pressure gradient (tighter isobars = stronger wind)
+
+## SIX-STEP ANALYTICAL PROCESS
+
+**IMPORTANT**: Steps 1-5 are your analytical working notes. These are your "thinking space" and do NOT count toward the 500-word final description. Only Step 6 produces the final description for the user.
+
+**Working Notes Format**: Steps 1-5 should contain abbreviated analytical notes showing your thinking process. Use specific values, coordinates, and observations. These notes document your analysis but are NOT included in the final description.
+
+### Step 1: Pure Data Extraction (200-300 words of working notes)
+**Objective**: Observe and record all quantitative information without interpretation
+
+**Extraction Requirements**:
+- **Domain**: Record exact lat/lon boundaries (check all edges - if it's global domain, it's 90°S to 90°N, 180°W to 180°E)
+- **Time**: Date, hour, timezone/UTC
+- **Variables Present**: List each with complete specifications
+
+**How to Extract Systematically**:
+1. Start at edges, work inward - note domain limits
+2. Scan temperature colorbar/legend - record full range AND intervals if applicable
+3. Identify isobar contour labels - note values and spacing
+4. List visible geographic features for later verification
+
+**Specific Items to Record**:
+- [ ] Temperature: Range, color mapping, contour interval
+- [ ] Pressure: Range, contour interval, specific labeled values
+- [ ] Geographic features: Continents, major water bodies visible
+- [ ] Grid specifications: Lat/lon line intervals
+
+**Example extraction**: "Temperature colorbar shows -40°C (deep purple) through 40°C (deep red) with approximately 2-3°C color gradations. Pressure contours visible from 1004 hPa to 1032 hPa, labeled at 4 hPa intervals (1004, 1008, 1012...)."
+
+### Step 2: Spatial Accuracy Verification (100-150 words of working notes)
+**Objective**: Verify all spatial information before interpretation
+
+**How to Verify Spatial Accuracy**:
+1. **Pressure Centers**: Find closed contours → locate center → check against geography
+ - Is that "Mediterranean high" actually over the Mediterranean?
+2. **Cross-Reference Method**: Use coastlines and geographic features as anchors
+ - If a low appears "over UK", verify it's at ~52°N, 0°E
+3. **Domain Check**: Confirm complete coverage claimed matches visible data
+
+**Verification Checklist**:
+- [ ] Each pressure center location checked against geography
+- [ ] Domain boundaries confirmed (no truncation)
+- [ ] Coordinate system consistent throughout
+- [ ] No confusion between coastlines and contours
+
+**Red Flags**: Features over wrong geography, impossible coordinates, domain mismatch
+
+### Step 3: Multi-Scale Pattern Recognition (200-300 words of working notes)
+**Objective**: Identify all meteorological features from global to local scales
+
+**How to Recognize Patterns Systematically**:
+1. **Global Scale First** (if applicable):
+ - Count major highs and lows
+ - Note latitude bands of temperature
+ - Identify any planetary wave patterns
+
+2. **Regional Scale**:
+ - Look for temperature gradients >5°C/500km
+ - Identify regional pressure systems
+ - Note areas of tight pressure gradients
+ - **For global charts**: Focus analysis on key populated regions (North America, Europe, East Asia, South America, Southern Africa, Australia)
+
+3. **Local Features**:
+ - Terrain influences (if visible)
+ - Isolated maxima/minima
+ - Mesoscale circulations
+
+**How to Rank Meteorological Significance**:
+1. **Intensity**: Compare to normal values for location/season
+2. **Size**: Larger systems generally more significant
+3. **Location**: Systems in unusual positions are notable
+4. **Gradients**: Strong gradients indicate active weather
+
+**Pattern List Format**: "Primary: 1040 hPa high at 45°N, 10°E (exceptional intensity). Secondary: Temperature gradient 15°C/1000km from 40°N to 50°N (potential frontal zone)..."
+
+### Step 4: Theoretical Validation (150-200 words of working notes)
+**Objective**: Validate identified patterns against meteorological theory
+
+**How to Validate Against Theory**:
+1. **Seasonal Check**:
+ - Is this pattern expected for the date/location?
+ - Example: "March Siberian high weakening - consistent with spring transition"
+
+2. **Circulation Consistency**:
+ - Do patterns align with three-cell model?
+ - Example: "Subtropical high at 30°N matches Hadley cell subsidence"
+
+3. **Physical Relationships**:
+ - Temperature-pressure coupling logical?
+ - Gradient strengths realistic?
+
+**Validation Framework**:
+- [ ] Major patterns consistent with season?
+- [ ] Pressure systems in climatologically reasonable locations?
+- [ ] Temperature patterns support pressure analysis?
+- [ ] Any features requiring special explanation?
+
+**How to Note Anomalies**: "The 1052 hPa high is ~12 hPa above normal for this location/date, suggesting exceptional subsidence..."
+
+### Step 5: Description Architecture Planning (100-150 words of working notes)
+**Objective**: Design the structure for the final description
+
+**Planning Components**:
+1. **Information Hierarchy**: List features in order of importance
+2. **Scale Integration Strategy**: How to connect global → regional → local
+3. **Regional Coverage Plan**:
+ - Geographic progression (W→E? N→S?)
+ - For global charts: Ensure coverage of priority regions
+4. **Dynamic Process Points**: Where to add circulation/weather implications
+
+**How to Plan Transitions**:
+- Global to regional: "This pattern manifests regionally as..."
+- Static to dynamic: "This high pressure system drives..."
+- Observed to inferred: "Based on the pressure gradient, inferred winds..."
+
+**Structural Outline Example**:
+1. Context with verified domain
+2. Lead with exceptional 1052 hPa high
+3. Connect to hemispheric temperature pattern
+4. Regional breakdown: Europe → Asia → Americas
+5. Synthesis of cold outbreak significance
+
+### Step 6: Final Scientific Description (450-500 words output)
+**Objective**: Synthesize all analysis into a comprehensive description
+
+**Required Components**:
+
+**1. Context Paragraph** (60-80 words):
+"This weather chart displays [variables] over [verified domain], spanning [exact lat] to [lat] and [exact lon] to [lon] for [date, time, season]. Temperature ranges from [min]°C ([color]) to [max]°C ([color]) with [interval]°C gradations. Pressure contours span [min] to [max] hPa at [interval] hPa intervals."
+
+**2. Primary Pattern Analysis** (100-150 words):
+- Most significant feature with coordinates
+- Theoretical validation statement
+- Dynamic implications (with inference notation)
+
+**3. Multi-Scale Integration** (100-150 words):
+- Explicit scale connections
+- How patterns interact across scales
+- Circulation patterns (noted as inferred)
+
+**4. Regional Analysis** (100-150 words):
+- Systematic geographic coverage
+- For global charts: Prioritize North America, Europe, East Asia, South America, Southern Africa, and Australia
+- For each region provide:
+ * Dominant pressure system and value
+ * Temperature range with specific values
+ * Inferred weather conditions
+ * Connection to larger-scale patterns
+- Integrated temperature-pressure relationships
+
+**5. Synthesis** (30-50 words):
+- Primary significance
+- Notable anomalies
+- Key inferences acknowledged
+
+## COMPLETE EXAMPLE: GLOBAL WEATHER PATTERN ANALYSIS
+
+### Step 1: Pure Data Extraction
+
+Chart type: Global weather map showing 2-meter temperature (2t) and mean sea level pressure (mslp)
+Domain: 90°S to 90°N, 180°W to 180°E (full global coverage)
+Date/Time: March 12, 2011, 12:00 UTC
+
+Temperature data:
+- Color range: -40°C to +40°C
+- Blue-green colors = negative temps, yellow-orange-red = positive temps
+- Color increments: ~2°C gradations
+
+Pressure data:
+- Contour range: ~970 hPa to 1028 hPa visible
+- Contour interval: 4 hPa
+- Labeled values seen: 976, 980, 984, 988, 992, 996, 1000, 1004, 1008, 1012, 1016, 1020, 1024, 1028
+
+Geographic features: All continents visible, major islands clear (Greenland, Madagascar, Japan, etc.)
+Grid: 30° lat/lon intervals
+
+
+### Step 2: Spatial Accuracy Verification
+
+High pressure centers verified:
+- Canada: ~1020 hPa at ~55°N, 100°W ✓
+- Central Europe: ~1024 hPa at ~50°N, 20°E ✓
+- Siberia: strong high ~55°N, 90°E ✓
+- South Atlantic: ~1020 hPa at ~35°S, 10°W ✓
+
+Low pressure centers:
+- Eastern U.S.: ~996 hPa at ~40°N, 75°W ✓
+- Scandinavia: ~1000 hPa at ~65°N, 15°E ✓
+- East of Japan: ~996 hPa at ~40°N, 170°E ✓
+- Southern Ocean: multiple 976-984 hPa centers between 50-70°S ✓
+
+All features align with continental positions - no errors detected.
+
+
+### Step 3: Multi-Scale Pattern Recognition
+
+Global patterns:
+- Three-cell circulation evident
+- Subtropical highs ~30° both hemispheres
+- Strong circumpolar low belt 50-70°S
+- Equatorial warm zone continuous
+- Polar cooling both ends
+
+Regional priorities (for populated areas):
+NORTH AMERICA: Cold high over Canada (1020 hPa), deep low eastern U.S. (996 hPa)
+EUROPE: Blocking high Central Europe (1024 hPa), Scandinavian low (1000 hPa)
+EAST ASIA: Siberian high influence north, maritime south, Japan low (996 hPa)
+SOUTH AMERICA: Frontal zone 40-55°S between polar low and subtropical ridge
+SOUTHERN AFRICA: South Atlantic High (1020 hPa) dominance
+AUSTRALIA: Low south of Tasmania (~980 hPa), high to north (1016 hPa)
+
+Temperature patterns:
+- Max temps 35-40°C: Sahara, Arabia, northern India
+- Min temps -40°C: Antarctica, -30°C Arctic
+- Strong gradients at frontal boundaries
+
+
+### Step 4: Theoretical Validation
+
+March 12 = late NH winter, late SH summer
+
+Seasonal checks:
+- Siberian high present but weakening for March ✓
+- Antarctic lows strengthening toward winter ✓
+- ITCZ slightly north of equator ✓
+
+Three-cell model:
+- Subtropical highs at ~30° = Hadley subsidence ✓
+- Mid-lat storms ~60° = polar front ✓
+- Warm equator, cold poles ✓
+
+Physical consistency:
+- Cold air with high pressure (Canada, Siberia) ✓
+- Warm subtropical highs ✓
+- Temperature gradients match frontal zones ✓
+
+All patterns consistent with late winter/early spring NH transition.
+
+
+### Step 5: Description Architecture Planning
+
+Priority order:
+1. Context paragraph with full specs
+2. Global three-cell pattern confirmation
+3. Regional analysis in order:
+ - North America (cold high vs eastern low)
+ - Europe (blocking pattern)
+ - East Asia (continental vs maritime)
+ - South America (frontal zone)
+ - Southern Africa (subtropical high)
+ - Australia (frontal progression)
+4. Seasonal transition synthesis
+
+Dynamic elements to include:
+- High pressure → subsidence → clear/cold or clear/warm
+- Low pressure → convergence → clouds/precip
+- Gradients → winds (inferred)
+
+Keep regional temps specific, pressure values exact.
+
+
+### Step 6: Final Scientific Description
+
+This global weather chart displays 2-meter temperature and mean sea level pressure for March 12, 2011, at 12:00 UTC, representing late boreal winter conditions. The domain spans 90°S to 90°N and 180°W to 180°E. Temperature ranges from -40°C (deep blue) to +40°C (deep red) with 2°C color increments. Pressure contours extend from 970 to 1028 hPa at 4 hPa intervals.
+
+The global pressure pattern validates the three-cell circulation model, with subtropical highs positioned near 30° latitude and subpolar lows around 60° in both hemispheres. This late-season configuration shows weakening Northern Hemisphere winter systems contrasting with strengthening Southern Hemisphere features approaching austral winter.
+
+In North America, a cold high-pressure system (1020 hPa) centered at 55°N, 100°W dominates Canada, maintaining temperatures between -15°C and -40°C through radiational cooling under clear skies (inferred from high pressure). A strong low-pressure system (996 hPa) over the eastern United States at 40°N, 75°W draws warmer, moist Atlantic air northward, creating temperatures of 0°C to 15°C in the southern states. This configuration produces a sharp frontal zone with likely precipitation (inferred from pressure contrast).
+
+Europe experiences contrasting conditions split by a blocking high-pressure system (1024 hPa) over Central Europe. This high promotes stable conditions with mild temperatures of 10°C to 20°C, particularly across southern regions benefiting from increased March insolation. Conversely, a low-pressure system (1000 hPa) over Scandinavia maintains colder temperatures of -10°C to 5°C with potentially unsettled weather (inferred from low pressure).
+
+East Asia shows typical late-winter continental-maritime contrasts. The Siberian high maintains cold conditions (-25°C to 5°C) across northern regions, while southern and eastern China experience warmer temperatures (15°C to 35°C) under high pressure with maritime influence. A developing low-pressure system (996 hPa) positioned at 40°N, 170°E east of Japan indicates active cyclogenesis with strong winds likely (inferred from tight pressure gradients).
+
+South America's weather reflects the transition between tropical and polar influences. A strong circumpolar low south of Patagonia and a subtropical ridge over central Argentina create intense pressure gradients driving westerly winds (inferred from pressure pattern). Southern Chile and Patagonia experience cool temperatures (0°C to 8°C) with likely frontal precipitation, while Buenos Aires remains warm (20°C to 25°C) under stable subtropical conditions.
+
+Southern Africa sits under the South Atlantic subtropical high (1020 hPa) centered at 35°S, 10°W, bringing warm (25°C to 35°C), dry, stable late-summer conditions across the region, particularly maintaining aridity over the western coastal deserts.
+
+Australia displays typical autumn transition patterns with a low-pressure system (980 hPa) south of Tasmania and high pressure (1016 hPa) to the north. Western regions remain warm and dry (20°C to 30°C), while the southeastern coast experiences cooler temperatures (15°C to 25°C) with approaching frontal systems bringing cloud and potential precipitation (inferred from pressure configuration).
+
+This chart captures the global atmosphere during seasonal transition, with each hemisphere displaying characteristic late-season patterns modified by regional geography and ocean-continent thermal contrasts.
+
+
+## CRITICAL CONSTRAINTS AND SPECIFICATIONS
+
+### Process vs. Product Distinction
+- **Steps 1-5**: Analytical working notes (not included in final description, should be as long as needed)
+- **Step 6 only**: Produces the 450-500 word final description
+- **Working notes**: Use as much space and words as needed for thorough analysis
+
+### Quantitative Precision Requirements
+- **Spatial accuracy**: All coordinates within ±2° tolerance
+- **Systematic frameworks**: Always include intervals, not just ranges in the context paragraph
+- **Verification**: Explicitly confirm or correct spatial accuracy in Step 2
+- **Complete specifications**: Every variable with range, units, AND structure
+
+### Language and Inference Requirements
+- **Maximum sentence length**: 25 words
+- **Inference notation**: Always mark inferred features (e.g., "inferred from pressure gradients")
+- **Prohibited phrases**: "as shown," "visible," "looking at," "clearly," "obviously"
+- **Required distinctions**: Observed data vs. theoretical inferences
+
+### Common Errors to Prevent
+1. **Spatial misplacement**: Always verify coordinates in Step 2
+2. **Domain truncation**: Global = 90°S to 90°N, not 60°S to 60°N
+3. **False precision**: Don't claim to observe unmarked fronts or air masses
+4. **Scale isolation**: Always connect local to regional to global
+5. **Static description**: Transform observations into dynamic processes
+6. **Generic statements**: Ban "complex pressure systems" without specifics
+
+## SUCCESS VERIFICATION
+
+Your analysis succeeds when a blind meteorologist can:
+1. Locate all major features within 2° accuracy
+2. Understand the theoretical framework validating the patterns
+3. Grasp multi-scale interactions and their significance
+4. Make informed weather predictions from your description
+5. Distinguish clearly between observed and inferred information
+
+## XML OUTPUT FORMAT
+
+**Required XML Tags** (place on separate lines):
+- `...` - Data extraction notes
+- `...` - Verification notes
+- `...` - Pattern recognition notes
+- `...` - Validation notes
+- `...` - Planning notes
+- `...` - ONLY the final 450-500 word description
+
+Remember: You are creating a complete analytical instrument that preserves the full scientific power of visual weather analysis for blind scientists. Precision and systematic methodology are essential for research quality and operational safety.
+"""
+
+
+def get_default_generator_user_prompt() -> str:
+ """Get the default user prompt for the weather chart description generator.
+
+ Returns:
+ str: The default user prompt for the generator agent.
+ """
+ return DEFAULT_GENERATOR_USER_PROMPT
diff --git a/src/earth_reach_agent/core/prompts/orchestrator.py b/src/earth_reach/core/prompts/orchestrator.py
similarity index 83%
rename from src/earth_reach_agent/core/prompts/orchestrator.py
rename to src/earth_reach/core/prompts/orchestrator.py
index 702639c..abcad04 100644
--- a/src/earth_reach_agent/core/prompts/orchestrator.py
+++ b/src/earth_reach/core/prompts/orchestrator.py
@@ -1,3 +1,10 @@
+"""
+Orchestrator prompts module.
+
+Contains default prompt templates used by the orchestrator component
+to provide feedback between generator and evaluator iterations.
+"""
+
DEFAULT_FEEDBACK_TEMPLATE = """## EVALUATOR FEEDBACK
Evaluation number: {evaluation_id}
diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py
new file mode 100644
index 0000000..6efea4a
--- /dev/null
+++ b/src/earth_reach/core/utils.py
@@ -0,0 +1,60 @@
+"""
+Utility functions module.
+
+Provides utility functions for image processing, file path operations,
+and data format conversions.
+"""
+
+import base64
+
+from io import BytesIO
+from pathlib import Path
+
+from PIL.ImageFile import ImageFile
+
+
+def img_to_base64(image_path: str | None = None, img: ImageFile | None = None) -> str:
+ """
+ Convert an image to a base64 string.
+
+ Args:
+ image_path (str): The path to the image file. Either this or img must be provided.
+ img (ImageFile | None): The image object. Either this or image_path must be provided.
+
+ Returns:
+ str: The base64 string representation of the image.
+ """
+ if image_path is None and img is None:
+ raise ValueError("Either image_path or img must be provided.")
+
+ if img is not None:
+ bytes_io = BytesIO()
+ img.save(bytes_io, format="PNG")
+ return base64.b64encode(bytes_io.getvalue()).decode("utf-8")
+
+ with open(image_path, "rb") as img_file: # type: ignore
+ return base64.b64encode(img_file.read()).decode("utf-8")
+
+
+def img_to_bytes(img: ImageFile) -> bytes:
+ """
+ Convert a PIL Image to bytes for Gemini API.
+
+ Args:
+ img: ImageFile object
+
+ Returns:
+ bytes: Image as bytes
+ """
+ if img is None:
+ raise ValueError("Image cannot be None")
+
+ bytes_io = BytesIO()
+ img.save(bytes_io, format="PNG")
+ return bytes_io.getvalue()
+
+
+def get_root_dir_path() -> Path:
+ """Get the root directory path of the project."""
+
+ return Path(__file__).parent.parent.parent.parent.resolve()
diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py
new file mode 100644
index 0000000..da127cd
--- /dev/null
+++ b/src/earth_reach/main.py
@@ -0,0 +1,231 @@
+"""
+Main module.
+
+Contains the main EarthReachAgent class that serves as the primary
+entry point for the dual-LLM weather chart description framework.
+"""
+
+from typing import Any
+
+import earthkit.data as ekd
+import earthkit.plots as ekp
+
+from earth_reach.config.criteria import QualityCriteria
+from earth_reach.config.logging import get_logger
+from earth_reach.core.evaluator import EvaluatorAgent
+from earth_reach.core.extractors.base_extractor import BaseDataExtractor
+from earth_reach.core.extractors.pressure_extractor import PressureCenterDataExtractor
+from earth_reach.core.generator import GeneratorAgent
+from earth_reach.core.llm import create_llm
+from earth_reach.core.orchestrator import Orchestrator
+from earth_reach.core.prompts.generator import get_default_generator_user_prompt
+
+logger = get_logger(__name__)
+
+
+class EarthReachAgent:
+ """
+ Main agent class for generating weather chart descriptions.
+
+ This class is meant to be the main entrypoint for the earthkit.plots integration.
+ To generate descriptions from images, instead of figure and data objects, use the provided CLI.
+
+ Provides a high-level interface for the dual-LLM framework, handling
+ data validation, component initialization, and orchestrated generation.
+ """
+
+ def __init__(
+ self,
+ provider: str,
+ model_name: str | None = None,
+ max_iterations: int = 3,
+ criteria_threshold: int = 4,
+ ) -> None:
+ """
+ Initialize the EarthReachAgent with LLM provider and configuration parameters.
+
+ Args:
+ provider: LLM provider name (e.g., "openai", "gemini", "groq", "anthropic")
+ model_name: Specific model name to use (optional, uses provider default)
+ max_iterations: Maximum number of iterations for the orchestrator
+ criteria_threshold: Minimum score for evaluation criteria to pass
+ """
+ self.provider = provider
+ self.model_name = model_name
+ self.required_vars = {"2t", "msl"}
+ self.max_iterations = max_iterations
+ self.criteria_threshold = criteria_threshold
+ logger.info(
+ "EarthReachAgent initialized with provider=%s, model=%s, max_iterations=%d, criteria_threshold=%d",
+ provider,
+ model_name or "default",
+ max_iterations,
+ criteria_threshold,
+ )
+
+ def _validate_inputs(self, figure: Any, data: Any) -> None:
+ """
+ Validate that inputs are of the correct types and contain required data.
+
+ Args:
+ figure: Should be an earthkit.plots.Figure object
+ data: Should be an earthkit.data.FieldList object
+
+ Raises:
+ TypeError: If inputs are not of the expected types
+ ValueError: If required variables are missing from data
+ """
+ logger.debug("Validating inputs...")
+
+ if not isinstance(figure, ekp.Figure):
+ raise TypeError(f"Expected earthkit.plots.Figure, got {type(figure)}")
+
+ if not isinstance(data, ekd.FieldList):
+ raise TypeError(f"Expected earthkit.data.FieldList, got {type(data)}")
+
+ available_vars = set()
+ try:
+ for field in data:
+ param = field.metadata("param")
+ if param:
+ available_vars.add(param)
+ except Exception as e:
+ logger.warning("Could not extract parameter metadata from data: %s", e)
+ return
+
+ missing_vars = self.required_vars - available_vars
+ if missing_vars:
+ raise ValueError(f"Required variables missing from data: {missing_vars}")
+
+ def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor]:
+ """
+ Create appropriate data extractors based on available variables in the data.
+
+ Args:
+ data: FieldList containing GRIB data
+
+ Returns:
+ List of data extractor instances
+
+ Raises:
+ RuntimeError: If extractor creation fails
+ """
+ logger.debug("Creating data extractors...")
+ extractors = []
+
+ available_vars = set()
+ for field in data:
+ param = field.metadata("param")
+ if param:
+ available_vars.add(param)
+
+ if "msl" in available_vars:
+ try:
+ extractors.append(PressureCenterDataExtractor())
+ logger.debug("Pressure centers data extractor created")
+ except Exception as e:
+ logger.debug("Could not create pressure centers data extractor: %s", e)
+
+ logger.info("Created %d data extractors", len(extractors))
+ return extractors
+
+ def _setup_components(
+ self, data_extractors: list[BaseDataExtractor]
+ ) -> Orchestrator:
+ """
+ Initialize LLM, agents, and orchestrator with the provided data extractors.
+
+ Args:
+ data_extractors: List of data extractor instances
+
+ Returns:
+ Configured orchestrator instance
+
+ Raises:
+ RuntimeError: If component setup fails
+ """
+ try:
+ logger.debug("Initializing components...")
+ llm = create_llm(provider=self.provider, model_name=self.model_name)
+
+ generator = GeneratorAgent(
+ llm=llm,
+ system_prompt=None,
+ user_prompt=get_default_generator_user_prompt(),
+ )
+
+ evaluator = EvaluatorAgent(
+ criteria=QualityCriteria.list(),
+ llm=llm,
+ )
+
+ orchestrator = Orchestrator(
+ generator_agent=generator,
+ evaluator_agent=evaluator,
+ data_extractors=data_extractors,
+ max_iterations=self.max_iterations,
+ criteria_threshold=self.criteria_threshold,
+ )
+
+ logger.info("Component setup completed successfully")
+ return orchestrator
+
+ except Exception as e:
+ raise RuntimeError(f"Failed to setup components: {e}") from e
+
+ def generate_alt_description(
+ self, figure: ekp.Figure, data: ekd.FieldList, use_extractors: bool = False
+ ) -> str:
+ """
+ Generate alternative text description for a weather chart.
+
+ Args:
+ figure: earthkit.plots Figure object containing the weather chart
+ data: earthkit.data FieldList containing GRIB meteorological data
+ use_extractors: Whether to use data extractors to enrich prompt or not. Default to False
+
+ Returns:
+ String description of the weather chart
+
+ Raises:
+ TypeError: If inputs are not of the expected types
+ ValueError: If required data is missing or invalid
+ RuntimeError: If description generation fails
+ """
+ try:
+ logger.info("Starting description generation...")
+
+ self._validate_inputs(figure, data)
+
+ if use_extractors:
+ logger.info("Creating data extractors to enrich prompts")
+ data_extractors = self._create_data_extractors(data)
+ else:
+ data_extractors = []
+
+ orchestrator = self._setup_components(data_extractors)
+
+ logger.info("Running orchestrated description generation...")
+ description = orchestrator.run(figure=figure, data=data)
+
+ if not isinstance(description, str):
+ raise TypeError(f"Expected string description, got {type(description)}")
+
+ if not description.strip():
+ raise ValueError("Generated description is empty")
+
+ logger.info(
+ "Description generation completed successfully (length: %d characters)",
+ len(description),
+ )
+ return description.strip()
+
+ except (TypeError, ValueError) as e:
+ logger.error("Input validation or data error: %s", e)
+ raise
+ except RuntimeError as e:
+ logger.error("Runtime error during generation: %s", e)
+ raise
+ except Exception as e:
+ logger.error("Unexpected error during description generation: %s", e)
+ raise RuntimeError(f"Failed to generate description: {e}") from e
diff --git a/src/earth_reach_agent/__init__.py b/src/earth_reach_agent/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/earth_reach_agent/config/__init__.py b/src/earth_reach_agent/config/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/earth_reach_agent/core/__init__.py b/src/earth_reach_agent/core/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py
deleted file mode 100644
index a9a1e02..0000000
--- a/src/earth_reach_agent/core/llm.py
+++ /dev/null
@@ -1,194 +0,0 @@
-import os
-
-import openai
-
-from earth_reach_agent.core.utils import img_to_base64
-
-
-class BaseLLM:
- """Base class for a LLM interface."""
-
- def __init__(
- self,
- model_name: str,
- base_url: str,
- api_key: str | None = None,
- ) -> None:
- """
- Initialize the LLM with a model name and optional keyword arguments.
-
- Args:
- model_name (str): The name of the model to use.
- base_url (str): The base URL for the LLM API.
- api_key (str | None): The API key for authentication with the LLM provider.
- **kwargs: Additional keyword arguments for the LLM configuration.
- """
- self.model_name = model_name
- self.base_url = base_url
- self.api_key = api_key
-
- self.client = openai.OpenAI(
- base_url=base_url,
- api_key=api_key,
- )
-
- def generate(
- self, user_prompt: str, system_prompt: str | None = None, image=None
- ) -> str:
- """
- Generate a response from the LLM API based on the user prompt and optional system prompt.
-
- Args:
- user_prompt (str): The prompt provided by the user to define the task.
- system_prompt (str | None): An optional system prompt to guide the model's response.
- image: Optional image to include in the request (will be converted to base64).
-
- Returns:
- str: The generated response content from the LLM.
-
- Raises:
- ValueError: If user_prompt is empty/None or if the API response is empty.
- RuntimeError: For other run-time errors.
- """
-
- if not user_prompt or not user_prompt.strip():
- raise ValueError("user_prompt cannot be empty or None")
-
- messages = []
-
- if system_prompt and system_prompt.strip():
- messages.append({"role": "system", "content": system_prompt.strip()})
-
- try:
- if image:
- base64_image = img_to_base64(img=image)
- if not base64_image:
- raise ValueError("Failed to convert image to base64")
-
- user_content = [
- {"type": "text", "text": user_prompt.strip()},
- {
- "type": "image_url",
- "image_url": {
- "url": f"data:image/jpeg;base64,{base64_image}",
- },
- },
- ]
- else:
- user_content = user_prompt.strip()
-
- messages.append({"role": "user", "content": user_content})
-
- except Exception as e:
- raise ValueError(f"Failed to process input data: {e}")
-
- try:
- response = self.client.chat.completions.create(
- model=self.model_name,
- messages=messages,
- )
-
- content = response.choices[0].message.content
-
- if not content or not content.strip():
- raise ValueError("The generated response content is empty")
-
- return content.strip()
-
- except ValueError:
- raise
- except Exception as e:
- error_msg = f"API call failed: {type(e).__name__}: {e}"
- raise RuntimeError(error_msg) from e
-
- def __repr__(self) -> str:
- return f"LLM(model_name={self.model_name}, base_url={self.base_url})"
-
-
-class GroqLLM(BaseLLM):
- """Implementation of the BaseLLM for Groq LLM API Provider."""
-
- def __init__(self, model_name: str, api_key: str | None = None) -> None:
- """Initialize the Groq LLM with a model name and optional API key.
-
- Args:
- model_name (str): The name of the Groq model to use.
- api_key (str | None): The API key for authentication with the Groq API.
-
- Raises:
- AssertionError: If the API key is not provided and not found in environment variables.
- """
-
- if not api_key:
- import os
-
- api_key = os.environ.get("GROQ_API_KEY", None)
- if not api_key:
- raise AssertionError(
- "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument."
- )
-
- super().__init__(
- model_name=model_name,
- api_key=api_key,
- base_url="https://api.groq.com/openai/v1",
- )
-
-
-class OpenAILLM(BaseLLM):
- """Implementation of the BaseLLM for OpenAI API Provider."""
-
- def __init__(self, model_name: str, api_key: str | None = None) -> None:
- """Initialize the OpenAI LLM with a model name and optional API key.
-
- Args:
- model_name (str): The name of the OpenAI model to use.
- api_key (str | None): The API key for authentication with the OpenAI API.
-
- Raises:
- AssertionError: If the API key is not provided and not found in environment variables.
- """
-
- if not api_key:
- import os
-
- api_key = os.environ.get("OPENAI_API_KEY", None)
- if not api_key:
- raise AssertionError(
- "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument."
- )
-
- super().__init__(
- model_name=model_name, api_key=api_key, base_url="https://api.openai.com/v1"
- )
-
-
-def create_llm(provider="groq") -> BaseLLM:
- """
- Create and return LLM instance.
-
- This function can be modified to support different LLM configurations
- or to read from environment variables/config files.
-
- Args:
- provider (str): LLM provider name (default: "groq")
-
- Returns:
- BaseLLM: Configured LLM instance
- """
- if provider.lower() == "groq":
- api_key = os.getenv("GROQ_API_KEY")
- if not api_key:
- raise ValueError("GROQ_API_KEY environment variable is not set.")
- return GroqLLM(
- model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key
- )
- elif provider.lower() == "openai":
- api_key = os.getenv("OPENAI_API_KEY")
- if not api_key:
- raise ValueError("OPENAI_API_KEY environment variable is not set.")
- return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key)
- else:
- raise ValueError(
- f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'."
- )
diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py
deleted file mode 100644
index 096325d..0000000
--- a/src/earth_reach_agent/core/orchestrator.py
+++ /dev/null
@@ -1,163 +0,0 @@
-import logging
-from typing import List
-
-import earthkit.plots as ekp
-from PIL.ImageFile import ImageFile
-
-from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent
-from earth_reach_agent.core.generator import GeneratorAgent
-from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template
-
-logger = logging.getLogger(__name__)
-
-
-class Orchestrator:
- """Orchestrates Generator and Evaluator agents to create weather chart descriptions."""
-
- def __init__(
- self,
- generator_agent: GeneratorAgent,
- evaluator_agent: EvaluatorAgent,
- max_iterations: int = 3,
- criteria_threshold: int = 4,
- feedback_template: str | None = None,
- ) -> None:
- """
- Initialize the orchestrator with generator and evaluator agents.
-
- Args:
- generator_agent: Instance of GeneratorAgent for generating descriptions
- evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions
- max_iterations: Maximum number of iterations for generating descriptions
- criteria_threshold: Minimum score for evaluation criteria to pass
- feedback_template: Template for feedback to the generator agent (optional)
- """
- self.generator_agent = generator_agent
- self.evaluator_agent = evaluator_agent
- self.max_iterations = max_iterations
- self.criteria_threshold = criteria_threshold
- self.feedback_template = feedback_template or get_default_feedback_template()
- self.criteria_limits_acknowledgment = {
- "coherence": "Warning: The logical flow and organization of this description may be unclear.",
- "fluency": "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.",
- "consistency": "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.",
- "relevance": "Warning: This description may not adequately emphasize the most meteorologically significant patterns.",
- }
-
- def run(
- self, figure: ekp.Figure | None = None, image: ImageFile | None = None
- ) -> str:
- """Run the iterative process of generating and evaluating a weather chart description
- until quality criteria are met.
-
- Args:
- figure (Figure | None): Optional figure to include in the request. Can't be used with image.
- image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure.
-
- Returns:
- str: The final weather description.
- """
- if figure is not None and image is not None:
- raise ValueError(
- "Only one of 'figure' or 'image' can be provided, not both."
- )
-
- try:
- for i in range(self.max_iterations):
- description = self.generator_agent.generate(figure=figure, image=image)
- if not description:
- raise ValueError("Generated description is empty.")
-
- evaluation = self.evaluator_agent.evaluate(
- description, image=image, figure=figure
- )
-
- if self.verify_evaluation_passes(evaluation):
- return description
-
- self.provide_feedback_to_generator(i + 1, description, evaluation)
-
- logger.warning(
- f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description."
- )
-
- description = self.acknowledge_limits_of_description(
- description, evaluation
- )
-
- return description
- except Exception as e:
- raise RuntimeError("Failed to generate a description") from e
-
- def verify_evaluation_passes(
- self, evaluation: List[CriterionEvaluatorOutput]
- ) -> bool:
- """
- Verify if the evaluation passes the quality criteria.
-
- Args:
- evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
-
- Returns:
- bool: True if evaluation passes, False otherwise
- """
- return all(
- criterion.score >= self.criteria_threshold for criterion in evaluation
- )
-
- def provide_feedback_to_generator(
- self,
- evaluation_id: int,
- description: str,
- evaluation: List[CriterionEvaluatorOutput],
- ) -> None:
- """
- Provide feedback to the GeneratorAgent based on evaluation results.
-
- Args:
- description (str): The description generated by the GeneratorAgent
- evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
- """
- unmet_criteria = [
- criterion
- for criterion in evaluation
- if criterion.score < self.criteria_threshold
- ]
- if not unmet_criteria:
- logger.warning("No unmet criteria found in evaluation.")
- return
-
- feedback = self.feedback_template.format(
- evaluation_id=evaluation_id,
- criteria_scores="\n- ".join(
- f"- {criterion.name}: {criterion.score}/5"
- for criterion in unmet_criteria
- ),
- criteria_reasoning="\n".join(
- f"- {criterion.name}: {criterion.reasoning or 'No reasoning available'}"
- for criterion in unmet_criteria
- ),
- description=description,
- )
-
- self.generator_agent.append_user_prompt(feedback)
-
- def acknowledge_limits_of_description(
- self, description: str, evaluation: List[CriterionEvaluatorOutput]
- ) -> str:
- """
- Acknowledge the limits of the generated description based on evaluation results.
-
- Args:
- description (str): The description generated by the GeneratorAgent
- evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent
- Returns:
- str: The description with acknowledgment of its limits added
- """
- acknowledgment = "\n"
- for criterion in evaluation:
- if criterion.score < self.criteria_threshold:
- crit_ackn = self.criteria_limits_acknowledgment.get(criterion.name, "")
- if crit_ackn:
- acknowledgment += f"\n{crit_ackn}"
- return description
diff --git a/src/earth_reach_agent/core/prompts/__init__.py b/src/earth_reach_agent/core/prompts/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/earth_reach_agent/core/prompts/evaluator.py b/src/earth_reach_agent/core/prompts/evaluator.py
deleted file mode 100644
index 364d84e..0000000
--- a/src/earth_reach_agent/core/prompts/evaluator.py
+++ /dev/null
@@ -1,567 +0,0 @@
-DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions
-
-## ROLE AND CONTEXT SETTING
-
-You are a scientific communication specialist evaluating weather chart descriptions for coherence. Your task is to assess how well a meteorological text description maintains logical flow and structural organization, specifically for blind scientists who rely entirely on textual information to understand complex weather patterns.
-
-**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research, teaching, and operational forecasting. Coherence failures directly impair scientific analysis and decision-making capabilities.
-
-## EVALUATION PROCESS
-
-### Step 1: Structural Analysis (Foundation Assessment)
-**Objective**: Examine the overall organizational framework
-
-**Assessment Actions**:
-1. Identify if the description follows a clear information hierarchy (overview → specific details → relationships)
-2. Verify logical progression from broad patterns to specific features
-3. Check for appropriate sectioning or natural information groupings
-4. Assess whether geographic and temporal context is established early
-5. Determine if the structure compensates for lack of visual reference points
-
-**Key Questions**:
-- Does the description start with essential context (region, time, variables)?
-- Is there a logical sequence from dominant to secondary meteorological features?
-- Are spatial relationships clearly established before detailed analysis?
-
-### Step 2: Transition and Flow Evaluation (Connection Assessment)
-**Objective**: Analyze how information elements connect and flow together
-
-**Assessment Actions**:
-1. Examine transitions between different meteorological systems or geographic regions
-2. Assess connection quality between quantitative data and qualitative patterns
-3. Verify smooth integration of coordinate references and spatial descriptions
-4. Check for logical bridges between related phenomena
-5. Identify any abrupt topic changes or missing connections
-
-**Key Questions**:
-- Are relationships between weather systems explicitly connected?
-- Do quantitative values integrate smoothly with descriptive analysis?
-- Are geographic transitions clearly signposted?
-
-### Step 3: Accessibility-Specific Coherence Check (Adaptation Assessment)
-**Objective**: Evaluate coherence from a blind scientist's perspective
-
-**Assessment Actions**:
-1. Assess whether information sequence builds spatial understanding progressively
-2. Verify that complex relationships are broken down into logical components
-3. Check for consistent reference frameworks (coordinates, directions, system names)
-4. Evaluate if the description enables mental model construction
-5. Ensure scientific conclusions flow logically from presented evidence
-
-**Key Questions**:
-- Can a blind scientist follow the spatial logic without visual aids?
-- Are complex meteorological relationships explained in digestible, sequential steps?
-- Does the description build understanding cumulatively?
-
-## SCORING FRAMEWORK
-
-**Score 5 - Exceptional Coherence**
-- Flawless logical progression from overview to detailed analysis
-- Seamless transitions that enhance understanding
-- Perfect adaptation for non-visual consumption
-- Information builds systematically to enable complete scientific comprehension
-- Structure actively facilitates meteorological analysis
-
-**Score 4 - Strong Coherence**
-- Clear logical flow with minor structural imperfections
-- Most transitions are smooth and helpful
-- Well-adapted for blind scientists with occasional navigation challenges
-- Information sequence supports scientific understanding effectively
-- Structure enhances rather than hinders analysis
-
-**Score 3 - Adequate Coherence**
-- Generally logical progression with noticeable structural issues
-- Some transitions are unclear or missing
-- Adequately accessible but requires extra effort to follow
-- Scientific understanding achievable but not optimally supported
-- Structure is functional but could be more effective
-
-**Score 2 - Poor Coherence**
-- Significant logical flow problems that impede understanding
-- Frequent unclear or missing transitions
-- Accessibility compromised by structural confusion
-- Scientific analysis hindered by organizational issues
-- Structure creates barriers to comprehension
-
-**Score 1 - Very Poor Coherence**
-- Major logical sequence problems throughout
-- Information poorly connected or randomly ordered
-- Severely compromised accessibility
-- Scientific understanding significantly impaired
-- Structure actively interferes with analysis
-
-**Score 0 - Incoherent**
-- No discernible logical organization
-- Unintelligible information sequence
-- Completely inaccessible structure
-- Scientific analysis impossible due to organizational chaos
-- No coherent structure present
-
-## COMMON PITFALLS TO AVOID
-
-1. **Visual Bias**: Penalize descriptions that assume visual understanding (e.g., "as seen in the chart")
-2. **Oversimplification**: Remember these are PhD-level scientists requiring sophisticated analysis
-3. **Length Confusion**: Coherence isn't about brevity—evaluate logical flow, not word count
-4. **Format Fixation**: Focus on logical progression, not rigid template adherence
-5. **Missing Context**: Consider that spatial relationships must be explicit without visual cues
-
-## OUTPUT REQUIREMENTS
-
-Provide your evaluation in the following XML format:
-
-```xml
-[Your detailed analysis explaining the score, referencing specific aspects of information flow, structural organization, and accessibility-adapted coherence. Include concrete examples from the description to support your assessment.]
-[0-5]
-```
-
-**Critical Requirements**:
-- Score must be an integer from 0 to 5
-- Reasoning should reference specific textual evidence
-- All XML tags must be properly closed
-- No additional formatting or text outside the XML structure
-
-**Success Check**: Your evaluation should enable a developer to understand exactly what coherence strengths or weaknesses exist in the description and how to improve them.
-"""
-
-DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Fluency" Quality Criteria Evaluation Prompt
-
-## ROLE AND CONTEXT SETTING
-
-You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns.
-
-**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Awkward phrasing, grammatical errors, or unclear expression can render precise meteorological data unusable for research and operational decisions.
-
-## EVALUATION PROCESS
-
-### Step 1: Grammar and Syntax Assessment (Foundation Analysis)
-**Objective**: Examine basic linguistic correctness and sentence construction
-
-**Assessment Actions**:
-1. Check for grammatical errors (subject-verb agreement, tense consistency, pronoun clarity)
-2. Assess sentence structure variety and appropriateness for scientific writing
-3. Verify punctuation accuracy, especially with complex quantitative information
-4. Examine adherence to 25-word sentence limit while maintaining clarity
-5. Identify any awkward or unclear sentence constructions
-
-**Key Questions**:
-- Are all sentences grammatically correct and properly punctuated?
-- Does sentence structure support clear communication of complex information?
-- Are sentences appropriately varied in structure while maintaining scientific tone?
-
-### Step 2: Technical Language and Terminology Evaluation (Precision Analysis)
-**Objective**: Assess accuracy and consistency of meteorological language use
-
-**Assessment Actions**:
-1. Verify correct usage of meteorological terminology per AMS Glossary standards
-2. Check consistency in unit usage (hPa, m/s, K or °C, km) throughout description
-3. Assess precision in coordinate and geographic references
-4. Evaluate appropriate level of technical vocabulary for PhD-level audience
-5. Identify any imprecise or ambiguous technical expressions
-
-**Key Questions**:
-- Are meteorological terms used correctly and consistently?
-- Do all quantitative values include appropriate and consistent units?
-- Is the technical language precise without being unnecessarily complex?
-
-### Step 3: Readability and Flow Assessment (Accessibility Analysis)
-**Objective**: Evaluate how well the text reads aloud and flows for screen reader users
-
-**Assessment Actions**:
-1. Assess natural reading rhythm and flow when text is read aloud
-2. Check for smooth integration of quantitative data within sentences
-3. Evaluate transition smoothness between sentences within paragraphs
-4. Verify absence of visual-dependent language that disrupts accessibility
-5. Assess whether complex spatial relationships are expressed clearly in linear text
-
-**Key Questions**:
-- Does the text flow naturally when read aloud or via screen reader?
-- Are numerical values and coordinates integrated smoothly into prose?
-- Is the language accessible to blind scientists without sacrificing precision?
-
-### Step 4: Scientific Voice and Consistency Check (Professional Standards Analysis)
-**Objective**: Evaluate maintenance of appropriate scientific tone and style
-
-**Assessment Actions**:
-1. Assess consistency in objective, scientific tone throughout description
-2. Verify appropriate separation of observations from interpretations
-3. Check for consistent perspective and voice (avoiding shifts between first/third person)
-4. Evaluate professional appropriateness of language choices
-5. Assess whether the writing maintains scientific credibility
-
-**Key Questions**:
-- Is the scientific tone consistent and appropriate throughout?
-- Does the language maintain objectivity while being engaging?
-- Are observations clearly distinguished from interpretations?
-
-## SCORING FRAMEWORK
-
-**Score 5 - Exceptional Fluency**
-- Flawless grammar, punctuation, and sentence construction
-- Perfect meteorological terminology usage and unit consistency
-- Exceptional readability optimized for screen reader accessibility
-- Seamless integration of complex quantitative information
-- Exemplary scientific voice that enhances comprehension
-
-**Score 4 - Strong Fluency**
-- Minor grammatical issues that don't impair understanding
-- Correct meteorological terminology with possible minor inconsistencies
-- Good readability with occasional flow disruptions
-- Quantitative information generally well-integrated
-- Professional scientific tone with minor voice variations
-
-**Score 3 - Adequate Fluency**
-- Some grammatical errors or awkward constructions
-- Generally correct terminology with noticeable inconsistencies
-- Readable but requires effort due to flow issues
-- Quantitative integration adequate but sometimes disruptive
-- Scientific tone maintained with occasional lapses
-
-**Score 2 - Poor Fluency**
-- Frequent grammatical errors that impede comprehension
-- Inconsistent or incorrect meteorological terminology usage
-- Significant readability issues that burden screen reader users
-- Poor integration of quantitative information
-- Inconsistent or inappropriate scientific tone
-
-**Score 1 - Very Poor Fluency**
-- Major grammatical problems throughout
-- Substantial meteorological terminology errors
-- Severely compromised readability
-- Quantitative information poorly expressed
-- Unprofessional or highly inconsistent tone
-
-**Score 0 - No Fluency**
-- Extensive grammatical errors making text barely comprehensible
-- Incorrect or inappropriate terminology throughout
-- Unreadable for accessibility technology users
-- Incomprehensible quantitative expressions
-- Completely inappropriate scientific voice
-
-## COMMON PITFALLS TO AVOID
-
-1. **Over-penalizing Complexity**: Don't confuse necessary scientific complexity with poor fluency
-2. **Visual Bias**: Remember accessibility requirements may create different but valid syntax patterns
-3. **Perfectionist Standards**: Minor issues shouldn't overshadow overall linguistic quality
-4. **Terminology Assumptions**: Verify meteorological term correctness rather than assuming
-5. **Screen Reader Ignorance**: Consider how punctuation and structure affect audio consumption
-
-## OUTPUT REQUIREMENTS
-
-Provide your evaluation in the following XML format:
-
-```xml
-[Your detailed analysis explaining the score, referencing specific examples of grammatical correctness, terminology usage, readability, and scientific voice. Include concrete textual evidence to support your assessment.]
-[0-5]
-```
-
-**Critical Requirements**:
-- Score must be an integer from 0 to 5
-- Reasoning should reference specific linguistic evidence from the description
-- All XML tags must be properly closed
-- No additional formatting or text outside the XML structure
-
-**Success Check**: Your evaluation should enable a developer to understand exactly what linguistic strengths or weaknesses exist in the description and provide actionable guidance for improvement.
-"""
-DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Consistency" Criteria Evaluation Prompt
-
-## ROLE AND CONTEXT SETTING
-
-You are a meteorological data validation specialist evaluating weather chart descriptions for factual accuracy and internal consistency. Your task is to assess how well a text description aligns with its source weather chart and whether all described elements are logically consistent with each other and with meteorological principles.
-
-**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research and operations. Consistency errors can lead to incorrect scientific conclusions, flawed forecasts, and compromised safety decisions in weather-sensitive operations.
-
-## EVALUATION PROCESS
-
-### Step 1: Source-Description Alignment Assessment (Fidelity Analysis)
-**Objective**: Verify accuracy between the weather chart and its textual description
-
-**Assessment Actions**:
-1. Compare described pressure values and locations with actual chart readings
-2. Verify temperature ranges and spatial distributions against chart data
-3. Check coordinate references (latitude/longitude) for major weather systems
-4. Validate wind patterns and directions if present in the chart
-5. Confirm temporal information (date, time, forecast period) matches chart metadata
-
-**Key Questions**:
-- Do the pressure values and system locations accurately reflect the chart?
-- Are temperature ranges and gradients correctly described?
-- Do coordinate references precisely match chart features?
-
-### Step 2: Internal Logical Consistency Check (Coherence Analysis)
-**Objective**: Assess whether all described elements align logically within the description
-
-**Assessment Actions**:
-1. Verify spatial relationships consistency (if A is north of B, coordinates should reflect this)
-2. Check that pressure gradients align with described system strengths
-3. Assess whether temperature-pressure relationships follow meteorological principles
-4. Validate that system interactions described are physically plausible
-5. Ensure consistent use of coordinate systems and measurement units throughout
-
-**Key Questions**:
-- Are spatial relationships between features internally consistent?
-- Do pressure and temperature patterns align with described weather systems?
-- Are all coordinate references using the same datum and format?
-
-### Step 3: Meteorological Plausibility Evaluation (Scientific Validity Analysis)
-**Objective**: Determine if described weather patterns follow atmospheric physics principles
-
-**Assessment Actions**:
-1. Assess whether pressure-temperature relationships follow expected atmospheric behavior
-2. Verify that wind patterns (if described) align with pressure gradients
-3. Check seasonal appropriateness of described patterns for the given date/location
-4. Evaluate whether system interactions described are meteorologically realistic
-5. Confirm that gradient magnitudes are physically reasonable
-
-**Key Questions**:
-- Do the described meteorological relationships follow atmospheric physics?
-- Are pressure gradients and system intensities realistic?
-- Do seasonal and geographic contexts match described patterns?
-
-### Step 4: Quantitative Accuracy Assessment (Precision Analysis)
-**Objective**: Evaluate numerical accuracy and measurement consistency
-
-**Assessment Actions**:
-1. Verify all pressure readings are within reasonable atmospheric ranges
-2. Check temperature values for geographic and seasonal appropriateness
-3. Assess coordinate precision and accuracy for major features
-4. Validate unit consistency throughout the description
-5. Confirm that value ranges accurately reflect chart data distribution
-
-**Key Questions**:
-- Are all quantitative values within realistic meteorological ranges?
-- Is unit usage consistent and appropriate throughout?
-- Do numerical ranges accurately represent chart data spread?
-
-## SCORING FRAMEWORK
-
-**Score 5 - Perfect Consistency**
-- Complete accuracy between chart and description
-- Flawless internal logical alignment
-- All meteorological relationships scientifically sound
-- Perfect quantitative precision with appropriate units
-- No contradictions or inconsistencies detected
-
-**Score 4 - Strong Consistency**
-- Minor discrepancies that don't affect scientific interpretation
-- Generally strong internal alignment with occasional minor issues
-- Meteorological relationships mostly sound with minor implausibilities
-- Quantitative accuracy high with possible minor unit inconsistencies
-- Negligible contradictions that don't impair understanding
-
-**Score 3 - Adequate Consistency**
-- Noticeable but not critical discrepancies with source chart
-- Some internal consistency issues that require clarification
-- Most meteorological relationships plausible with some questionable aspects
-- Generally accurate quantitative data with some unit or precision issues
-- Minor contradictions present but don't severely compromise description
-
-**Score 2 - Poor Consistency**
-- Significant discrepancies between chart and description
-- Multiple internal contradictions affecting understanding
-- Several meteorologically implausible relationships described
-- Quantitative errors that could mislead scientific analysis
-- Contradictions that substantially compromise description reliability
-
-**Score 1 - Very Poor Consistency**
-- Major inaccuracies compared to source chart
-- Extensive internal contradictions throughout description
-- Multiple violations of meteorological principles
-- Substantial quantitative errors affecting interpretation
-- Pervasive contradictions severely undermining description credibility
-
-**Score 0 - No Consistency**
-- Description bears little resemblance to source chart
-- Completely contradictory internal elements
-- Described patterns violate basic atmospheric physics
-- Quantitative data largely incorrect or nonsensical
-- Contradictions make description scientifically unusable
-
-## COMMON PITFALLS TO AVOID
-
-1. **Perfectionist Expectations**: Minor measurement variations don't always indicate major consistency problems
-2. **Chart Resolution Limits**: Consider that exact numerical precision may be limited by chart resolution
-3. **Meteorological Complexity**: Some apparent inconsistencies may reflect complex but real atmospheric phenomena
-4. **Temporal Snapshots**: Remember descriptions represent single time moments, not evolving patterns
-5. **Accessibility Adaptations**: Some rephrasing for blind users may appear different but remain factually consistent
-
-## OUTPUT REQUIREMENTS
-
-Provide your evaluation in the following XML format:
-
-```xml
-[Your detailed analysis explaining the score, referencing specific examples of source-description alignment, internal consistency, meteorological plausibility, and quantitative accuracy. Include concrete evidence from both the chart and description to support your assessment.]
-[0-5]
-```
-
-**Critical Requirements**:
-- Score must be an integer from 0 to 5
-- Reasoning should reference specific examples comparing chart features to description elements
-- All XML tags must be properly closed
-- No additional formatting or text outside the XML structure
-
-**Success Check**: Your evaluation should enable a developer to understand exactly what consistency strengths or weaknesses exist between the source chart and description, and provide actionable guidance for improving accuracy.
-"""
-
-DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Relevance" Criteria Evaluation Prompt
-
-## ROLE AND CONTEXT SETTING
-
-You are a meteorological analysis expert evaluating weather chart descriptions for scientific relevance and information prioritization. Your task is to assess whether a text description captures and emphasizes the most meteorologically significant patterns from the source weather chart, enabling blind scientists to conduct the same quality analysis as their sighted colleagues.
-
-**Critical Context**: These descriptions must distill complex weather charts into the most scientifically valuable information within strict word limits. Poor relevance assessment can render descriptions analytically useless, forcing blind meteorologists to miss critical weather patterns or waste time on insignificant details.
-
-## EVALUATION PROCESS
-
-### Step 1: Meteorological Significance Assessment (Primary Pattern Analysis)
-**Objective**: Evaluate whether the most important weather systems receive appropriate emphasis
-
-**Assessment Actions**:
-1. Identify the dominant meteorological features visible in the chart (strongest systems, major gradients)
-2. Assess whether these primary features receive proportional attention in the description
-3. Evaluate if system intensities and meteorological significance are accurately prioritized
-4. Check that the most analytically important patterns are emphasized early and clearly
-5. Verify that dominant weather drivers are given precedence over secondary effects
-
-**Key Questions**:
-- Are the strongest pressure systems and steepest gradients given primary focus?
-- Does the description lead with the most meteorologically significant patterns?
-- Are weather system intensities ranked appropriately in the text emphasis?
-
-### Step 2: Information Density Optimization (Efficiency Analysis)
-**Objective**: Assess how effectively the description uses its word limit for maximum scientific value
-
-**Assessment Actions**:
-1. Evaluate whether included quantitative details support primary meteorological conclusions
-2. Assess if spatial descriptions focus on scientifically important boundaries and gradients
-3. Check that coordinate references prioritize major system centers and critical transition zones
-4. Verify that secondary details don't overshadow primary patterns
-5. Determine if the description enables the same analytical conclusions as the visual chart
-
-**Key Questions**:
-- Do the included details directly support understanding of major weather patterns?
-- Are quantitative values focused on the most analytically important features?
-- Does the description enable equivalent scientific analysis to visual inspection?
-
-### Step 3: Analytical Enablement Evaluation (Research Utility Analysis)
-**Objective**: Determine if the description supports high-quality meteorological analysis
-
-**Assessment Actions**:
-1. Assess whether described patterns enable forecast reasoning and weather prediction
-2. Evaluate if the description supports identification of meteorological processes and mechanisms
-3. Check that enough context is provided for understanding system interactions
-4. Verify that the description enables comparison with climatological patterns
-5. Determine if operational or research decisions could be made based on the description
-
-**Key Questions**:
-- Can a meteorologist make informed forecasting decisions from this description?
-- Are the described patterns sufficient for understanding atmospheric processes?
-- Does the description support research-quality meteorological analysis?
-
-### Step 4: Contextual Appropriateness Assessment (Scale and Purpose Analysis)
-**Objective**: Evaluate whether information priorities match the chart's scale and meteorological context
-
-**Assessment Actions**:
-1. Assess appropriateness of detail level for the chart's spatial and temporal scale
-2. Evaluate whether seasonal and geographic context influences appropriate information priorities
-3. Check that described patterns match the meteorological significance for the given region/time
-4. Verify that the description addresses the most relevant phenomena for the chart's domain
-5. Assess whether the emphasis aligns with typical meteorological analysis priorities
-
-**Key Questions**:
-- Is the detail level appropriate for the chart's geographic and temporal scale?
-- Do the emphasized patterns match typical meteorological priorities for this context?
-- Are regionally or seasonally important features given appropriate attention?
-
-## SCORING FRAMEWORK
-
-**Score 5 - Exceptional Relevance**
-- Perfect identification and emphasis of most meteorologically significant features
-- Optimal information density maximizing scientific value within word limits
-- Description fully enables equivalent analytical capabilities to visual chart inspection
-- Information priorities perfectly matched to meteorological context and scale
-- Every included detail directly supports primary meteorological understanding
-
-**Score 4 - Strong Relevance**
-- Most important meteorological features appropriately emphasized
-- Good information density with minor inclusion of less critical details
-- Description enables high-quality meteorological analysis with minor limitations
-- Information priorities generally well-matched to context with occasional misalignment
-- Most details support primary meteorological conclusions effectively
-
-**Score 3 - Adequate Relevance**
-- Primary meteorological features identified but emphasis could be stronger
-- Reasonable information density but some space wasted on less important details
-- Description supports basic meteorological analysis but misses some analytical opportunities
-- Information priorities generally appropriate but some contextual mismatches
-- Mix of relevant and less relevant details affecting overall analytical efficiency
-
-**Score 2 - Poor Relevance**
-- Important meteorological features under-emphasized or missed
-- Poor information density with significant space devoted to minor details
-- Description provides limited analytical capability compared to visual chart
-- Information priorities poorly matched to meteorological context
-- Many included details don't support primary meteorological understanding
-
-**Score 1 - Very Poor Relevance**
-- Major meteorological features largely ignored or severely under-emphasized
-- Very poor information density focusing on insignificant details
-- Description severely limits meteorological analytical capability
-- Information priorities inappropriate for meteorological context and scale
-- Most included details irrelevant to primary meteorological understanding
-
-**Score 0 - No Relevance**
-- Description fails to identify or emphasize any significant meteorological patterns
-- Information completely unfocused with no clear meteorological priorities
-- Description provides no useful analytical capability
-- No appropriate meteorological context or scale consideration
-- Content largely irrelevant to meteorological analysis needs
-
-## COMMON PITFALLS TO AVOID
-
-1. **Completeness Confusion**: Don't confuse comprehensive coverage with relevance—focus beats breadth
-2. **Detail Fetishism**: Precise quantitative data doesn't automatically mean relevance
-3. **Template Thinking**: Different meteorological situations require different information priorities
-4. **Academic Bias**: Balance research completeness with analytical efficiency
-5. **Scale Mismatching**: Regional charts need different detail emphasis than global analyses
-
-## OUTPUT REQUIREMENTS
-
-Provide your evaluation in the following XML format:
-
-```xml
-[Your detailed analysis explaining the score, referencing specific examples of meteorological significance prioritization, information density optimization, analytical enablement, and contextual appropriateness. Include concrete evidence of what important information is emphasized or missed.]
-[0-5]
-```
-
-**Critical Requirements**:
-- Score must be an integer from 0 to 5
-- Reasoning should reference specific examples of information prioritization and meteorological significance
-- All XML tags must be properly closed
-- No additional formatting or text outside the XML structure
-
-**Success Check**: Your evaluation should enable a developer to understand exactly what meteorological information priorities are appropriate and how well the description serves analytical needs for blind scientists.
-"""
-
-
-def get_default_criterion_evaluator_user_prompt(criterion: str) -> str:
- """
- Get the default CriteriaEvaluatorAgent user prompt for the specified criterion.
-
- Args:
- criterion (str): The criterion for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance.
-
- Returns:
- str: The default criterion user prompt text.
- """
- if criterion == "coherence":
- return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT
- elif criterion == "fluency":
- return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT
- elif criterion == "consistency":
- return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT
- elif criterion == "relevance":
- return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT
- else:
- raise ValueError(
- f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance."
- )
diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py
deleted file mode 100644
index 61850b4..0000000
--- a/src/earth_reach_agent/core/prompts/generator.py
+++ /dev/null
@@ -1,173 +0,0 @@
-DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System
-
-## ROLE AND CONTEXT SETTING
-
-You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible.
-
-**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues.
-
-**Critical Context**: Your descriptions will be used for:
-- Research analysis and data interpretation
-- Scientific paper writing and peer review
-- Teaching and educational materials
-- Operational weather forecasting decisions
-
-## TASK DECOMPOSITION
-
-### Step 1: Data Extraction and Analysis (200-500 words)
-**Objective**: Systematically catalog all quantitative information and meteorological features
-
-**Actions**:
-1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.)
-2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global")
-3. List all meteorological systems visible (highs, lows, fronts, convergence zones)
-4. Document the time period, date, and any temporal information
-5. Record the data source, resolution, and any technical specifications shown
-
-**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"?
-
-### Step 2: Pattern Recognition and Spatial Analysis (200-500 words)
-**Objective**: Identify the dominant meteorological patterns and their spatial relationships
-
-**Actions**:
-1. Determine the primary weather systems and rank them by meteorological significance
-2. Map the directional flow of gradients (temperature, pressure) using cardinal directions
-3. Identify spatial correlations between different variables
-4. Note any unusual or anomalous features that deviate from expected patterns
-5. Assess the seasonal/temporal context and typical vs. atypical conditions
-
-**Success Check**: Can you explain how each major system influences the others spatially?
-
-### Step 3: Description Planning and Structure Design (100-150 words)
-**Objective**: Create a hierarchical outline that prioritizes information by scientific importance
-
-**Actions**:
-1. Rank meteorological features by their significance for understanding weather patterns
-2. Plan the logical flow from overview to specific details
-3. Determine which quantitative values are essential vs. supplementary
-4. Identify the most effective way to convey spatial relationships
-5. Plan transitions between sections to maintain scientific coherence
-
-**Success Check**: Would a meteorologist reading your outline understand the complete weather situation?
-
-### Step 4: Description Writing with Verification (300-500 words)
-**Objective**: Produce the final description following all specifications
-
-**Actions**:
-1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables
-2. Develop the main body with 4-6 sentences, covering:
- - Dominant systems and their characteristics
- - Variable distributions (temperature, pressure, wind)
- - Spatial relationships and correlations
- - Notable features or anomalies
-3. Ensure all quantitative values include specific ranges and units
-4. Use precise meteorological terminology and avoid visual-dependent language
-
-**Success Check**: Does your description pass all items in the quality verification checklist?
-
-## CONCRETE CONSTRAINTS AND SPECIFICATIONS
-
-### Length and Structure Requirements
-- **Total description length**: 300-500 words maximum
-- **Overview section**: Exactly 2-3 sentences
-- **Main body**: 4-6 sentences
-
-### Quantitative Requirements
-- **Include exact ranges**: Every variable must have minimum and maximum values with units
-- **Spatial precision**: Use cardinal directions and geographic references
-- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable)
-- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km)
-
-### Language Specifications
-- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience)
-- **Sentence structure**: Maximum 25 words per sentence
-- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below)
-- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..."
-
-## ERROR PREVENTION GUIDELINES
-
-### Common Pitfalls to Avoid
-1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values
-2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously"
-3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references
-4. **Unit omissions**: Every quantitative statement must include appropriate units
-5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly
-
-## SUCCESS CRITERIA
-
-### Objective Standards
-1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description
-2. **Quantitative Accuracy**: All numerical values within ±5% of actual data
-3. **Accessibility Compliance**: 100% of visual information conveyed through text
-4. **Scientific Utility**: Description enables the same analytical conclusions as the visual
-5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards
-
-## EXAMPLES AND REFERENCE PATTERNS
-
-Not yet available, but will be provided in the future.
-
-## OUTPUT FORMAT REQUIREMENTS
-
-### XML Tag Structure
-**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing.
-
-**Required XML Tags:**
-- `...` - Wrap the entire content of Step 1 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 2 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 3 (excluding the markdown header)
-- `...` - Wrap the entire content of Step 4 (excluding the markdown header)
-- `...` - Wrap only the final consolidated description (without headers or meta-commentary)
-
-### Formatting Rules
-1. **Tag Placement**: Place opening and closing tags on their own lines
-2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X)
-3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements
-4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief
-
-### Example Structure:
-```
-## Step 1: Data Extraction and Analysis
-
-The chart displays 2-meter temperature and mean sea level pressure...
-[rest of step 1 content]
-
-
-## Step 2: Pattern Recognition and Spatial Analysis
-
-The dominant feature is a high-pressure system...
-[rest of step 2 content]
-
-
-## Step 3: Description Planning and Structure Design
-
-To structure the description, we will start with an overview...
-[rest of step 3 content]
-
-
-## Step 4: Description Writing with Verification
-
-### Overview
-This weather chart displays...
-### Main Body
-A high-pressure system is centered...
-[rest of step 4 content]
-
-
-
-This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center.
-
-```
-
-**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures.
-
-**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists.
-"""
-
-
-def get_default_generator_user_prompt() -> str:
- """Get the default user prompt for the weather chart description generator.
-
- Returns:
- str: The default user prompt for the generator agent.
- """
- return DEFAULT_GENERATOR_USER_PROMPT
diff --git a/src/earth_reach_agent/core/utils.py b/src/earth_reach_agent/core/utils.py
deleted file mode 100644
index 98d1f95..0000000
--- a/src/earth_reach_agent/core/utils.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import base64
-from io import BytesIO
-
-
-def img_to_base64(image_path: str | None = None, img=None) -> str:
- """
- Convert an image to a base64 string.
-
- Args:
- image_path (str): The path to the image file. Either this or img must be provided.
- img (PIL.Image): The image object. Either this or image_path must be provided.
-
- Returns:
- str: The base64 string representation of the image.
- """
- if image_path is None and img is None:
- raise ValueError("Either image_path or img must be provided.")
-
- if img is not None:
- bytes_io = BytesIO()
- img.save(bytes_io, format="PNG")
- return base64.b64encode(bytes_io.getvalue()).decode("utf-8")
-
- with open(image_path, "rb") as img_file: # type: ignore
- return base64.b64encode(img_file.read()).decode("utf-8")
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
new file mode 100644
index 0000000..73b4114
--- /dev/null
+++ b/src/tests/__init__.py
@@ -0,0 +1 @@
+# Tests package for EarthReach project
diff --git a/src/tests/unit/__init__.py b/src/tests/unit/__init__.py
new file mode 100644
index 0000000..4a5d263
--- /dev/null
+++ b/src/tests/unit/__init__.py
@@ -0,0 +1 @@
+# Unit tests package
diff --git a/uv.lock b/uv.lock
index 8aa5b6a..c94c88e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -16,6 +16,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/1c/8feedd607cc14c5df9aef74fe3af9a99bf660743b842a9b5b1865326b4aa/adjustText-1.3.0-py3-none-any.whl", hash = "sha256:da23d7b24b6db5ffa039bb136bfa556207365e32f48ac74b07ad26dd485bc691", size = 13154 },
]
+[[package]]
+name = "alabaster"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -25,6 +34,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
+[[package]]
+name = "anthropic"
+version = "0.60.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/03/3334921dc54ed822b3dd993ae72d823a7402588521bbba3e024b3333a1fd/anthropic-0.60.0.tar.gz", hash = "sha256:a22ba187c6f4fd5afecb2fc913b960feccf72bc0d25c1b7ce0345e87caede577", size = 425983 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/bb/d84f287fb1c217b30c328af987cf8bbe3897edf0518dcc5fa39412f794ec/anthropic-0.60.0-py3-none-any.whl", hash = "sha256:65ad1f088a960217aaf82ba91ff743d6c89e9d811c6d64275b9a7c59ee9ac3c6", size = 293116 },
+]
+
[[package]]
name = "anyio"
version = "4.9.0"
@@ -84,6 +111,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 },
]
+[[package]]
+name = "babel"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 },
+]
+
+[[package]]
+name = "cachetools"
+version = "5.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
+]
+
[[package]]
name = "cartopy"
version = "0.24.1"
@@ -179,6 +224,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/d7/96b4209c99f1fd6c19f502cebe8c91983c23331c380f3f521250f268ae8c/cfgrib-0.9.15.0-py3-none-any.whl", hash = "sha256:469cfd25dc173863795e596263b3b6b5ea1402b1715f2b7b1d4b995b40b32c18", size = 48908 },
]
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 },
+]
+
[[package]]
name = "cftime"
version = "1.6.4.post1"
@@ -364,6 +418,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/03/3bf7c3646135433a2e7de2866132d15e08594f44b9c21c443adb735adf43/covjsonkit-0.1.11-py3-none-any.whl", hash = "sha256:774a8fead55342c0f4435b2afb0d210dc49fbfe3da972811903ad7b28beca8d1", size = 477387 },
]
+[[package]]
+name = "crc32c"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/4c/4e40cc26347ac8254d3f25b9f94710b8e8df24ee4dddc1ba41907a88a94d/crc32c-2.7.1.tar.gz", hash = "sha256:f91b144a21eef834d64178e01982bb9179c354b3e9e5f4c803b0e5096384968c", size = 45712 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/02/998dc21333413ce63fe4c1ca70eafe61ca26afc7eb353f20cecdb77d614e/crc32c-2.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f7d1c4e761fe42bf856130daf8b2658df33fe0ced3c43dadafdfeaa42b57b950", size = 49568 },
+ { url = "https://files.pythonhosted.org/packages/9c/3e/e3656bfa76e50ef87b7136fef2dbf3c46e225629432fc9184fdd7fd187ff/crc32c-2.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:73361c79a6e4605204457f19fda18b042a94508a52e53d10a4239da5fb0f6a34", size = 37019 },
+ { url = "https://files.pythonhosted.org/packages/0b/7d/5ff9904046ad15a08772515db19df43107bf5e3901a89c36a577b5f40ba0/crc32c-2.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:afd778fc8ac0ed2ffbfb122a9aa6a0e409a8019b894a1799cda12c01534493e0", size = 35373 },
+ { url = "https://files.pythonhosted.org/packages/4d/41/4aedc961893f26858ab89fc772d0eaba91f9870f19eaa933999dcacb94ec/crc32c-2.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ef661b34e9f25991fface7f9ad85e81bbc1b3fe3b916fd58c893eabe2fa0b8", size = 54675 },
+ { url = "https://files.pythonhosted.org/packages/d6/63/8cabf09b7e39b9fec8f7010646c8b33057fc8d67e6093b3cc15563d23533/crc32c-2.7.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:571aa4429444b5d7f588e4377663592145d2d25eb1635abb530f1281794fc7c9", size = 52386 },
+ { url = "https://files.pythonhosted.org/packages/79/13/13576941bf7cf95026abae43d8427c812c0054408212bf8ed490eda846b0/crc32c-2.7.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c02a3bd67dea95cdb25844aaf44ca2e1b0c1fd70b287ad08c874a95ef4bb38db", size = 53495 },
+ { url = "https://files.pythonhosted.org/packages/3d/b6/55ffb26d0517d2d6c6f430ce2ad36ae7647c995c5bfd7abce7f32bb2bad1/crc32c-2.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99d17637c4867672cb8adeea007294e3c3df9d43964369516cfe2c1f47ce500a", size = 54456 },
+ { url = "https://files.pythonhosted.org/packages/c2/1a/5562e54cb629ecc5543d3604dba86ddfc7c7b7bf31d64005b38a00d31d31/crc32c-2.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4a400ac3c69a32e180d8753fd7ec7bccb80ade7ab0812855dce8a208e72495f", size = 52647 },
+ { url = "https://files.pythonhosted.org/packages/48/ec/ce4138eaf356cd9aae60bbe931755e5e0151b3eca5f491fce6c01b97fd59/crc32c-2.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:588587772e55624dd9c7a906ec9e8773ae0b6ac5e270fc0bc84ee2758eba90d5", size = 53332 },
+ { url = "https://files.pythonhosted.org/packages/5e/b5/144b42cd838a901175a916078781cb2c3c9f977151c9ba085aebd6d15b22/crc32c-2.7.1-cp312-cp312-win32.whl", hash = "sha256:9f14b60e5a14206e8173dd617fa0c4df35e098a305594082f930dae5488da428", size = 38371 },
+ { url = "https://files.pythonhosted.org/packages/ae/c4/7929dcd5d9b57db0cce4fe6f6c191049380fc6d8c9b9f5581967f4ec018e/crc32c-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:7c810a246660a24dc818047dc5f89c7ce7b2814e1e08a8e99993f4103f7219e8", size = 39805 },
+ { url = "https://files.pythonhosted.org/packages/bf/98/1a6d60d5b3b5edc8382777b64100343cb4aa6a7e172fae4a6cfcb8ebbbd9/crc32c-2.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:24949bffb06fc411cc18188d33357923cb935273642164d0bb37a5f375654169", size = 49567 },
+ { url = "https://files.pythonhosted.org/packages/4f/56/0dd652d4e950e6348bbf16b964b3325e4ad8220470774128fc0b0dd069cb/crc32c-2.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d5d326e7e118d4fa60187770d86b66af2fdfc63ce9eeb265f0d3e7d49bebe0b", size = 37018 },
+ { url = "https://files.pythonhosted.org/packages/47/02/2bd65fdef10139b6a802d83a7f966b7750fe5ffb1042f7cbe5dbb6403869/crc32c-2.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba110df60c64c8e2d77a9425b982a520ccdb7abe42f06604f4d98a45bb1fff62", size = 35374 },
+ { url = "https://files.pythonhosted.org/packages/a9/0d/3e797d1ed92d357a6a4c5b41cea15a538b27a8fdf18c7863747eb50b73ad/crc32c-2.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c277f9d16a3283e064d54854af0976b72abaa89824955579b2b3f37444f89aae", size = 54641 },
+ { url = "https://files.pythonhosted.org/packages/a7/d3/4ddeef755caaa75680c559562b6c71f5910fee4c4f3a2eb5ea8b57f0e48c/crc32c-2.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881af0478a01331244e27197356929edbdeaef6a9f81b5c6bacfea18d2139289", size = 52338 },
+ { url = "https://files.pythonhosted.org/packages/01/cf/32f019be5de9f6e180926a50ee5f08648e686c7d9a59f2c5d0806a77b1c7/crc32c-2.7.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:724d5ff4d29ff093a983ae656be3307093706d850ea2a233bf29fcacc335d945", size = 53447 },
+ { url = "https://files.pythonhosted.org/packages/b2/8b/92f3f62f3bafe8f7ab4af7bfb7246dc683fd11ec0d6dfb73f91e09079f69/crc32c-2.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2416c4d88696ac322632555c0f81ab35e15f154bc96055da6cf110d642dbc10", size = 54484 },
+ { url = "https://files.pythonhosted.org/packages/98/b2/113a50f8781f76af5ac65ffdb907e72bddbe974de8e02247f0d58bc48040/crc32c-2.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:60254251b88ec9b9795215f0f9ec015a6b5eef8b2c5fba1267c672d83c78fc02", size = 52703 },
+ { url = "https://files.pythonhosted.org/packages/b4/6c/309229e9acda8cf36a8ff4061d70b54d905f79b7037e16883ce6590a24ab/crc32c-2.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edefc0e46f3c37372183f70338e5bdee42f6789b62fcd36ec53aa933e9dfbeaf", size = 53367 },
+ { url = "https://files.pythonhosted.org/packages/b5/2a/6c6324d920396e1bd9f3efbe8753da071be0ca52bd22d6c82d446b8d6975/crc32c-2.7.1-cp313-cp313-win32.whl", hash = "sha256:813af8111218970fe2adb833c5e5239f091b9c9e76f03b4dd91aaba86e99b499", size = 38377 },
+ { url = "https://files.pythonhosted.org/packages/db/a0/f01ccfab538db07ef3f6b4ede46357ff147a81dd4f3c59ca6a34c791a549/crc32c-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:7d9ede7be8e4ec1c9e90aaf6884decbeef10e3473e6ddac032706d710cab5888", size = 39803 },
+ { url = "https://files.pythonhosted.org/packages/1b/80/61dcae7568b33acfde70c9d651c7d891c0c578c39cc049107c1cf61f1367/crc32c-2.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db9ac92294284b22521356715784b91cc9094eee42a5282ab281b872510d1831", size = 49386 },
+ { url = "https://files.pythonhosted.org/packages/1e/f1/80f17c089799ab2b4c247443bdd101d6ceda30c46d7f193e16b5ca29c5a0/crc32c-2.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8fcd7f2f29a30dc92af64a9ee3d38bde0c82bd20ad939999427aac94bbd87373", size = 36937 },
+ { url = "https://files.pythonhosted.org/packages/63/42/5fcfc71a3de493d920fd2590843762a2749981ea56b802b380e5df82309d/crc32c-2.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c056ef043393085523e149276a7ce0cb534b872e04f3e20d74d9a94a75c0ad7", size = 35292 },
+ { url = "https://files.pythonhosted.org/packages/03/de/fef962e898a953558fe1c55141644553e84ef4190693a31244c59a0856c7/crc32c-2.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03a92551a343702629af91f78d205801219692b6909f8fa126b830e332bfb0e0", size = 54223 },
+ { url = "https://files.pythonhosted.org/packages/21/14/fceca1a6f45c0a1814fe8602a65657b75c27425162445925ba87438cad6b/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb9424ec1a8ca54763155a703e763bcede82e6569fe94762614bb2de1412d4e1", size = 51588 },
+ { url = "https://files.pythonhosted.org/packages/13/3b/13d40a7dfbf9ef05c84a0da45544ee72080dca4ce090679e5105689984bd/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88732070f6175530db04e0bb36880ac45c33d49f8ac43fa0e50cfb1830049d23", size = 52678 },
+ { url = "https://files.pythonhosted.org/packages/36/09/65ffc4fb9fa60ff6714eeb50a92284a4525e5943f0b040b572c0c76368c1/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57a20dfc27995f568f64775eea2bbb58ae269f1a1144561df5e4a4955f79db32", size = 53847 },
+ { url = "https://files.pythonhosted.org/packages/24/71/938e926085b7288da052db7c84416f3ce25e71baf7ab5b63824c7bcb6f22/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f7186d098bfd2cff25eac6880b7c7ad80431b90610036131c1c7dd0eab42a332", size = 51860 },
+ { url = "https://files.pythonhosted.org/packages/3c/d8/4526d5380189d6f2fa27256c204100f30214fe402f47cf6e9fb9a91ab890/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:55a77e29a265418fa34bef15bd0f2c60afae5348988aaf35ed163b4bbf93cf37", size = 52508 },
+ { url = "https://files.pythonhosted.org/packages/19/30/15f7e35176488b77e5b88751947d321d603fccac273099ace27c7b2d50a6/crc32c-2.7.1-cp313-cp313t-win32.whl", hash = "sha256:ae38a4b6aa361595d81cab441405fbee905c72273e80a1c010fb878ae77ac769", size = 38319 },
+ { url = "https://files.pythonhosted.org/packages/19/c4/0b3eee04dac195f4730d102d7a9fbea894ae7a32ce075f84336df96a385d/crc32c-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:eee2a43b663feb6c79a6c1c6e5eae339c2b72cfac31ee54ec0209fa736cf7ee5", size = 39781 },
+]
+
[[package]]
name = "cycler"
version = "0.12.1"
@@ -444,6 +539,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 },
]
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -463,7 +567,28 @@ wheels = [
]
[[package]]
-name = "earth-reach-agent"
+name = "docutils"
+version = "0.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 },
+]
+
+[[package]]
+name = "donfig"
+version = "0.8.1.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592 },
+]
+
+[[package]]
+name = "earth-reach"
version = "0.1.0"
source = { editable = "." }
dependencies = [
@@ -474,29 +599,64 @@ dependencies = [
{ name = "hatchling" },
{ name = "openai" },
{ name = "python-dotenv" },
+ { name = "scipy" },
+ { name = "zarr" },
+]
+
+[package.optional-dependencies]
+all-models = [
+ { name = "anthropic" },
+ { name = "google-genai" },
+]
+claude = [
+ { name = "anthropic" },
+]
+gemini = [
+ { name = "google-genai" },
]
[package.dev-dependencies]
dev = [
{ name = "ipykernel" },
{ name = "jupytext" },
+ { name = "mypy" },
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "ruff" },
+ { name = "scipy-stubs" },
+ { name = "sphinx" },
+ { name = "sphinx-rtd-theme" },
]
[package.metadata]
requires-dist = [
+ { name = "anthropic", marker = "extra == 'all-models'", specifier = ">=0.60.0" },
+ { name = "anthropic", marker = "extra == 'claude'", specifier = ">=0.60.0" },
{ name = "earthkit", specifier = ">=0.10.3" },
{ name = "earthkit-data", specifier = ">=0.13.8" },
{ name = "earthkit-plots", specifier = ">=0.3.1" },
{ name = "fire", git = "https://github.com/google/python-fire.git?rev=2025-03-23-ipython" },
+ { name = "google-genai", marker = "extra == 'all-models'", specifier = ">=1.27.0" },
+ { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.27.0" },
{ name = "hatchling", specifier = ">=1.27.0" },
{ name = "openai", specifier = ">=1.77.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
+ { name = "scipy", specifier = ">=1.15.2" },
+ { name = "zarr", specifier = ">=3.1.1" },
]
+provides-extras = ["gemini", "claude", "all-models"]
[package.metadata.requires-dev]
dev = [
{ name = "ipykernel", specifier = ">=6.29.5" },
{ name = "jupytext", specifier = ">=1.17.1" },
+ { name = "mypy", specifier = ">=1.8.0" },
+ { name = "pre-commit", specifier = ">=4.0.1" },
+ { name = "pytest", specifier = ">=8.3.5" },
+ { name = "ruff", specifier = ">=0.12.7" },
+ { name = "scipy-stubs", specifier = ">=1.16.1.0" },
+ { name = "sphinx", specifier = ">=8.2.3" },
+ { name = "sphinx-rtd-theme", specifier = ">=3.0.2" },
]
[[package]]
@@ -858,6 +1018,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/64/7d344cfcef5efddf9cf32f59af7f855828e9d74b5f862eddf5bfd9f25323/geopandas-1.0.1-py3-none-any.whl", hash = "sha256:01e147d9420cc374d26f51fc23716ac307f32b49406e4bd8462c07e82ed1d3d6", size = 323587 },
]
+[[package]]
+name = "google-auth"
+version = "2.40.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachetools" },
+ { name = "pyasn1-modules" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137 },
+]
+
+[[package]]
+name = "google-genai"
+version = "1.27.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "google-auth" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "tenacity" },
+ { name = "typing-extensions" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/37/6c0ececc3a7a629029b5beed2ceb9f28f73292236eb96272355636769b0d/google_genai-1.27.0.tar.gz", hash = "sha256:15a13ffe7b3938da50b9ab77204664d82122617256f55b5ce403d593848ef635", size = 220099 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/12/279afe7357af73f9737a3412b6f0bc1482075b896340eb46a2f9cb0fd791/google_genai-1.27.0-py3-none-any.whl", hash = "sha256:afd6b4efaf8ec1d20a6e6657d768b68d998d60007c6e220e9024e23c913c1833", size = 218489 },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -923,6 +1116,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
+[[package]]
+name = "identify"
+version = "2.6.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -932,6 +1134,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 },
+]
+
[[package]]
name = "iniconfig"
version = "2.1.0"
@@ -1364,6 +1575,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/b4/4dd3f8f8bdb79bf65a1882fbf769a27e3ce27e9566faa0aeaa295ed755d7/multiurl-0.3.5-py3-none-any.whl", hash = "sha256:37b920c3116861198ec5b24080fed5344514006021eec969784dabc76fcf3d63", size = 21323 },
]
+[[package]]
+name = "mypy"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295 },
+ { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355 },
+ { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285 },
+ { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895 },
+ { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025 },
+ { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664 },
+ { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338 },
+ { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066 },
+ { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473 },
+ { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296 },
+ { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657 },
+ { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320 },
+ { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037 },
+ { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550 },
+ { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963 },
+ { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189 },
+ { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322 },
+ { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879 },
+ { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411 },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
+]
+
[[package]]
name = "narwhals"
version = "1.37.1"
@@ -1420,42 +1672,115 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/b5/e04550fd53de57001dbd5a87242da7ff784c80790adc48897977b6ccf891/netCDF4-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:5c5fbee6134ee1246c397e1508e5297d825aa19221fdf3fa8dc9727ad824d7a5", size = 6990521 },
]
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
+]
+
+[[package]]
+name = "numcodecs"
+version = "0.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/35/49da850ce5371da3930d099da364a73ce9ae4fc64075e521674b48f4804d/numcodecs-0.16.1.tar.gz", hash = "sha256:c47f20d656454568c6b4697ce02081e6bbb512f198738c6a56fafe8029c97fb1", size = 6268134 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ee/e2a903c88fed347dc74c70bbd7a8dab9aa22bb0dac68c5bc6393c2e9373b/numcodecs-0.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1abe0651ecb6f207656ebfc802effa55c4ae3136cf172c295a067749a2699122", size = 1663434 },
+ { url = "https://files.pythonhosted.org/packages/f2/f0/37819d4f6896b1ac43a164ffd3ab99d7cbf63bf63cb375fef97aedaef4f0/numcodecs-0.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:abb39b7102d0816c8563669cdddca40392d34d0cbf31e3e996706b244586a458", size = 1150402 },
+ { url = "https://files.pythonhosted.org/packages/60/3c/5059a29750305b80b7428b1e6695878dea9ea3b537d7fba57875e4bbc2c7/numcodecs-0.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3359a951f8b23317f12736a7ad1e7375ec3d735465f92049c76d032ebca4c40", size = 8237455 },
+ { url = "https://files.pythonhosted.org/packages/1b/f5/515f98d659ab0cbe3738da153eddae22186fd38f05a808511e10f04cf679/numcodecs-0.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82cc70592ec18060786b1bfa0da23afd2a7807d7975d766e626954d6628ec609", size = 8770711 },
+ { url = "https://files.pythonhosted.org/packages/a2/3a/9fc6104f888af11bad804ebd32dffe0bcb83337f4525b4fe5b379942fefd/numcodecs-0.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b48ddc8a7d132b7808bc53eb2705342de5c1e39289d725f988bd143c0fd86df", size = 788701 },
+ { url = "https://files.pythonhosted.org/packages/5e/1e/73ffb1074f03d52cb1c4f4deaba26a2008ca45262f3622ed26dbec7a7362/numcodecs-0.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ad8ee940315f59188accfc3f2d39726a4ca0d76b49bf8d0018e121f01c49028", size = 1659453 },
+ { url = "https://files.pythonhosted.org/packages/42/72/5affb1ce92b7a6becee17921de7c6b521a48fa61fc3d36d9f1eea2cf83f5/numcodecs-0.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:179ca7bf3525a0f7379df7767d87dd495253de44597cb7e511198b28b09da633", size = 1143932 },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/b092679d84c67c6ed62e4df5781d89bbb089f24a0df4187cbab9db51cf6b/numcodecs-0.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e2babbb50bf348ae982818d5560af330eab0dcd925fb0e49509785ad57d11db", size = 8187716 },
+ { url = "https://files.pythonhosted.org/packages/a8/e8/86e7741adb43261aff409b53c53c8bac2797bfca055d64dd65dc731d5141/numcodecs-0.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4b29d8d3284b72bfad4fb83d672a17f497ae86ee1ef8087bac7222b620d3d91", size = 8728650 },
+ { url = "https://files.pythonhosted.org/packages/21/03/87c5c217232aa3515d350728c6dcefca252fa582246100ef68a51fbda456/numcodecs-0.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:06489635f43e1a959aea73cb830d78cf3adb07ac5f34daccb92091e4d9ac6b07", size = 785553 },
+]
+
+[package.optional-dependencies]
+crc32c = [
+ { name = "crc32c" },
+]
+
[[package]]
name = "numpy"
-version = "2.2.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e2/f7/1fd4ff108cd9d7ef929b8882692e23665dc9c23feecafbb9c6b80f4ec583/numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051", size = 20948633 },
- { url = "https://files.pythonhosted.org/packages/12/03/d443c278348371b20d830af155ff2079acad6a9e60279fac2b41dbbb73d8/numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc", size = 14176123 },
- { url = "https://files.pythonhosted.org/packages/2b/0b/5ca264641d0e7b14393313304da48b225d15d471250376f3fbdb1a2be603/numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e", size = 5163817 },
- { url = "https://files.pythonhosted.org/packages/04/b3/d522672b9e3d28e26e1613de7675b441bbd1eaca75db95680635dd158c67/numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa", size = 6698066 },
- { url = "https://files.pythonhosted.org/packages/a0/93/0f7a75c1ff02d4b76df35079676b3b2719fcdfb39abdf44c8b33f43ef37d/numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571", size = 14087277 },
- { url = "https://files.pythonhosted.org/packages/b0/d9/7c338b923c53d431bc837b5b787052fef9ae68a56fe91e325aac0d48226e/numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073", size = 16135742 },
- { url = "https://files.pythonhosted.org/packages/2d/10/4dec9184a5d74ba9867c6f7d1e9f2e0fb5fe96ff2bf50bb6f342d64f2003/numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8", size = 15581825 },
- { url = "https://files.pythonhosted.org/packages/80/1f/2b6fcd636e848053f5b57712a7d1880b1565eec35a637fdfd0a30d5e738d/numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae", size = 17899600 },
- { url = "https://files.pythonhosted.org/packages/ec/87/36801f4dc2623d76a0a3835975524a84bd2b18fe0f8835d45c8eae2f9ff2/numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb", size = 6312626 },
- { url = "https://files.pythonhosted.org/packages/8b/09/4ffb4d6cfe7ca6707336187951992bd8a8b9142cf345d87ab858d2d7636a/numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282", size = 12645715 },
- { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102 },
- { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709 },
- { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173 },
- { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502 },
- { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417 },
- { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807 },
- { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611 },
- { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747 },
- { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594 },
- { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356 },
- { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778 },
- { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279 },
- { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247 },
- { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087 },
- { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964 },
- { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214 },
- { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788 },
- { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672 },
- { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102 },
- { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096 },
+version = "2.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420 },
+ { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660 },
+ { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382 },
+ { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258 },
+ { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409 },
+ { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317 },
+ { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262 },
+ { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342 },
+ { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610 },
+ { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292 },
+ { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071 },
+ { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074 },
+ { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311 },
+ { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022 },
+ { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135 },
+ { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147 },
+ { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989 },
+ { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052 },
+ { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955 },
+ { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843 },
+ { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876 },
+ { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786 },
+ { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395 },
+ { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374 },
+ { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864 },
+ { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533 },
+ { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007 },
+ { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914 },
+ { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708 },
+ { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678 },
+ { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832 },
+ { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049 },
+ { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935 },
+ { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906 },
+ { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607 },
+ { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110 },
+ { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050 },
+ { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292 },
+ { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913 },
+ { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180 },
+ { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809 },
+ { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410 },
+ { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821 },
+ { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303 },
+ { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524 },
+ { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519 },
+ { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972 },
+ { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439 },
+ { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479 },
+ { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805 },
+ { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830 },
+ { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665 },
+ { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777 },
+ { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856 },
+ { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226 },
+]
+
+[[package]]
+name = "numpy-typing-compat"
+version = "2.3.20250730"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/20/f2a7b68572d1e011a13bdaf0452de9cc6c53b0685371134db6c78d4c824b/numpy_typing_compat-2.3.20250730.tar.gz", hash = "sha256:eb30717dc7390a038c2247be1a7e8bf1a48b8a4701a01960804830a231631bef", size = 4707 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/47/ca494f6f2366f17bd2a79b6c1468f6c441a447b017fbdf600aa40b5d27ac/numpy_typing_compat-2.3.20250730-py3-none-any.whl", hash = "sha256:9ab0cd4bb1b4c31debf0bd745554c735a07a7bb3cc3801dc934b2b5856549612", size = 6057 },
]
[[package]]
@@ -1493,6 +1818,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/58/37ae3ca75936b824a0a5ca30491c968192007857319d6836764b548b9d9b/openai-1.77.0-py3-none-any.whl", hash = "sha256:07706e91eb71631234996989a8ea991d5ee56f0744ef694c961e0824d4f39218", size = 662031 },
]
+[[package]]
+name = "optype"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/b5/c295280c848a622e402d1f4c329d0f4d8055e281a140d3ca52aa8eda462d/optype-0.13.1.tar.gz", hash = "sha256:9b1d590597b0c093faf5253e38238c5a5e1a1f9afb04530836c38ac94fdd5cf6", size = 99030 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/0f/35fd26222a48cb4601c62616f992586b39fdcb963bd362bbeec435f81355/optype-0.13.1-py3-none-any.whl", hash = "sha256:811c6b4c3d40e10b6602e33a546b30b5e595e9ec7c59e050b5e328332ed2659c", size = 87632 },
+]
+
+[package.optional-dependencies]
+numpy = [
+ { name = "numpy" },
+ { name = "numpy-typing-compat" },
+]
+
[[package]]
name = "orjson"
version = "3.10.18"
@@ -1735,6 +2078,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/75/e3fd211c9b273df86ff387b989c794b6bd44ddc01dd00c720a18bfd01cde/polytope_client-0.7.4-py3-none-any.whl", hash = "sha256:fc038b41c6e0c6ad11efc8953f58e6399ef65e66dfa3953edfbb84510f49a823", size = 49680 },
]
+[[package]]
+name = "pre-commit"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 },
+]
+
[[package]]
name = "prompt-toolkit"
version = "3.0.51"
@@ -1780,6 +2139,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 },
]
+[[package]]
+name = "pyasn1"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 },
+]
+
[[package]]
name = "pycparser"
version = "2.22"
@@ -2135,6 +2515,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/9c/dc7cbeb99a7b7422392ed7f327efdbb958bc0faf424aef5f130309320bda/rich_argparse-1.7.0-py3-none-any.whl", hash = "sha256:b8ec8943588e9731967f4f97b735b03dc127c416f480a083060433a97baf2fd3", size = 25339 },
]
+[[package]]
+name = "roman-numerals-py"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742 },
+]
+
[[package]]
name = "rpds-py"
version = "0.24.0"
@@ -2182,6 +2571,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 },
]
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 },
+]
+
+[[package]]
+name = "ruff"
+version = "0.12.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189 },
+ { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389 },
+ { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384 },
+ { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759 },
+ { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028 },
+ { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209 },
+ { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353 },
+ { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555 },
+ { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556 },
+ { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784 },
+ { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356 },
+ { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124 },
+ { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945 },
+ { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677 },
+ { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687 },
+ { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365 },
+ { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083 },
+]
+
[[package]]
name = "scipy"
version = "1.15.2"
@@ -2220,6 +2646,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 },
]
+[[package]]
+name = "scipy-stubs"
+version = "1.16.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "optype", extra = ["numpy"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e5/27/bc9a81cabc1cb24aca77d221d70e9d62677b984da9a20292511b80183895/scipy_stubs-1.16.1.0.tar.gz", hash = "sha256:4251c10d0a0a47b54916c49f62cc7b411ab0f7b78ea3946a156990110982d6d9", size = 344964 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/1b/9afd7b859c3067845631c65bff8a1ffa59de122e0af121ff17c476f625cd/scipy_stubs-1.16.1.0-py3-none-any.whl", hash = "sha256:6ce677a485391012cbcc0e28897b5dd6031a1b5dda40b2ceecbbc5d4f9cc03d5", size = 551168 },
+]
+
[[package]]
name = "shapely"
version = "2.1.0"
@@ -2273,6 +2711,123 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274 },
+]
+
+[[package]]
+name = "sphinx"
+version = "8.2.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals-py" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741 },
+]
+
+[[package]]
+name = "sphinx-rtd-theme"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "sphinx" },
+ { name = "sphinxcontrib-jquery" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561 },
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 },
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 },
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 },
+]
+
+[[package]]
+name = "sphinxcontrib-jquery"
+version = "4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 },
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 },
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 },
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 },
+]
+
[[package]]
name = "stack-data"
version = "0.6.3"
@@ -2287,6 +2842,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 },
]
+[[package]]
+name = "tenacity"
+version = "8.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165 },
+]
+
[[package]]
name = "termcolor"
version = "3.1.0"
@@ -2392,6 +2956,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
]
+[[package]]
+name = "virtualenv"
+version = "20.33.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/2e/8a70dcbe8bf15213a08f9b0325ede04faca5d362922ae0d62ef0fa4b069d/virtualenv-20.33.0.tar.gz", hash = "sha256:47e0c0d2ef1801fce721708ccdf2a28b9403fa2307c3268aebd03225976f61d2", size = 6082069 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/87/b22cf40cdf7e2b2bf83f38a94d2c90c5ad6c304896e5a12d0c08a602eb59/virtualenv-20.33.0-py3-none-any.whl", hash = "sha256:106b6baa8ab1b526d5a9b71165c85c456fbd49b16976c88e2bc9352ee3bc5d3f", size = 6060205 },
+]
+
[[package]]
name = "wcwidth"
version = "0.2.13"
@@ -2401,6 +2979,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 },
]
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
+]
+
[[package]]
name = "xarray"
version = "2025.4.0"
@@ -2414,3 +3023,19 @@ sdist = { url = "https://files.pythonhosted.org/packages/9b/29/37761364e137db138
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/1e/96fd96419fec1a37da998a1ca3d558f2cae2f6f3cd5015170371b05a2b6b/xarray-2025.4.0-py3-none-any.whl", hash = "sha256:b27defd082c5cb85d32c695708de6bb05c2838fb7caaf3f952982e602a35b9b8", size = 1290171 },
]
+
+[[package]]
+name = "zarr"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "donfig" },
+ { name = "numcodecs", extra = ["crc32c"] },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/15/a9/29fe1800380092ae03ac6207d757f3e5affaf1fcd2e5ef074cf4fc68f0fa/zarr-3.1.1.tar.gz", hash = "sha256:17db72f37f2489452d2137ac891c4133b8f976f9189d8efd3e75f3b3add84e8c", size = 314075 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/48/bde2f58cfbc9fd6ab844e2f2fd79d5e54195c12a17aa9b47c0b0e701a421/zarr-3.1.1-py3-none-any.whl", hash = "sha256:9a0b7e7c27bf62965b8eef6b8b8fdb9b47381f0738be35e40f37be6479b546be", size = 255373 },
+]
diff --git a/vllm/.env.example b/vllm/.env.example
index 5a08d0f..e7a6846 100644
--- a/vllm/.env.example
+++ b/vllm/.env.example
@@ -10,6 +10,7 @@ HF_CACHE_DIR=/dev/shm/hf-cache
# VLLM configuration
MODEL_NAME="google/gemma-3-4b-it"
+MODEL_DIR_PATH=
HF_HUB_TOKEN=
VLLM_SERVER_API_KEY=
-VLLM_PORT=8000
\ No newline at end of file
+VLLM_PORT=8000
diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml
index d40891f..caef703 100644
--- a/vllm/docker-compose.yaml
+++ b/vllm/docker-compose.yaml
@@ -31,7 +31,7 @@ services:
# - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}"
# - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}"
# - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}"
-
+
traefik:
image: traefik:v2.9
container_name: traefik
diff --git a/vllm/setup.md b/vllm/setup.md
index 42e0988..d9838a5 100644
--- a/vllm/setup.md
+++ b/vllm/setup.md
@@ -4,13 +4,26 @@ To setup this private LLM inference server, you will need:
- To have access to a linux machine
- Docker and docker compose installed
-- A domain name pointing towards your linux macine's public IP address
+- A domain name pointing towards your linux machine's public IP address (configured with Cloudflare for DNS and SSL)
+- A Cloudflare account with API access for DNS challenges
## Installation
We provide a helper script `setup.sh` to facilitate the configuration. Please review it before using it.
-Please set the right values for the environment variables in a `vllm/.env` file (see `.env.example`).
+Please set the right values for the environment variables in a `vllm/.env` file (see `.env.example`):
+
+- `DOMAIN`: Your domain name (e.g., api.yourdomain.com)
+- `CF_DNS_TOKEN`: Cloudflare API token with DNS permissions
+- `CF_ACME_EMAIL`: Email address for Let's Encrypt certificates
+- `CF_IPS`: Cloudflare IP ranges for proxy protocol
+- `TRAEFIK_ROOT_DIR`: Directory for Traefik configuration and certificates
+- `HF_CACHE_DIR`: Directory for Hugging Face model cache
+- `MODEL_NAME`: The model to serve (e.g., "google/gemma-3-4b-it")
+- `MODEL_DIR_PATH`: Full path to the downloaded model directory
+- `HF_HUB_TOKEN`: Hugging Face Hub token for model access
+- `VLLM_SERVER_API_KEY`: API key for accessing the VLLM server
+- `VLLM_PORT`: Port for the VLLM server (default: 8000)
```sh
chmod +x ./setup.sh
@@ -25,18 +38,16 @@ Start by making sure your server is up to date:
```sh
# Search for updates and apply them
-sudo dnf upgrade
+sudo dnf upgrade
```
### Firewall
Let's allow HTTP, HTTPS connections and deny the other types by default.
-TODO(high): update to only accept requests from cloudflare servers for requests other than SSH
-
```sh
-# Install firewalld
-sudo dnf install firewalld
+# Install firewalld
+sudo dnf install firewalld
# Start and enable firewalld
sudo systemctl start firewalld
@@ -71,7 +82,7 @@ curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-contai
# Install nvidia container toolkit
sudo dnf install -y nvidia-container-toolkit
-# Configure the nvidia runtime for docker containers
+# Configure the nvidia runtime for docker containers
sudo nvidia-ctk runtime configure --runtime=docker
# Restart docker
@@ -94,17 +105,27 @@ sudo dnf install -y docker-ce \
sudo systemctl enable --now docker
```
-TODO: replace caddy configuration with traefik configuration instructions
-### Caddy
+### Traefik
```sh
-# Create necessary Caddy directories
-mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR
+# Create necessary Traefik directories
+mkdir -p $TRAEFIK_ROOT_DIR/traefik/cert
-# Copy the Caddyfile to the config directory
-cp Caddyfile $CADDY_FILE_PATH
+# Set proper permissions for the certificate storage
+chmod 600 $TRAEFIK_ROOT_DIR/traefik/cert
```
+### Cloudflare Configuration
+
+This setup uses Cloudflare for DNS management and SSL certificate provisioning via DNS challenges. You'll need:
+
+1. **Domain Configuration**: Your domain must be managed by Cloudflare
+2. **API Token**: Create a Cloudflare API token with the following permissions:
+ - Zone:Zone:Read
+ - Zone:DNS:Edit
+ - Zone:Zone Settings:Read
+3. **Trusted IPs**: Configure Cloudflare's IP ranges for proxy protocol (set in CF_IPS environment variable)
+
## Usage
Try requesting the server with:
@@ -112,10 +133,13 @@ Try requesting the server with:
```sh
curl https://$DOMAIN/v1/completions \
-H "Content-Type: application/json" \
+ -H "Authorization: Bearer $VLLM_SERVER_API_KEY" \
-d '{
"model": "$MODEL_NAME",
"prompt": "What's the weather like in Bologna ?",
"max_tokens": 20,
"temperature": 0.1
}'
-```
\ No newline at end of file
+```
+
+Note: Make sure to enable the Traefik labels in your docker-compose.yaml by uncommenting the labels section for the vllm-server service before running `docker compose up -d`.
diff --git a/vllm/setup.sh b/vllm/setup.sh
index 4784daa..ece6d9b 100644
--- a/vllm/setup.sh
+++ b/vllm/setup.sh
@@ -11,7 +11,6 @@ sudo systemctl start firewalld
sudo systemctl enable firewalld
# Add SSH, HTTP and HTTPS services
-# TODO(high): update to only accept requests from cloudflare servers for requests other than SSH
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
@@ -39,19 +38,18 @@ curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-
# Install nvidia container toolkit
sudo dnf install -y nvidia-container-toolkit
-# Configure the nvidia runtime for docker containers
+# Configure the nvidia runtime for docker containers
sudo nvidia-ctk runtime configure --runtime=docker
# Restart docker
sudo systemctl docker restart
-# TODO: replace caddy automated configuration with traefik
-# Create necessary Caddy directories
-mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR
+# Create necessary Traefik directories
+mkdir -p $TRAEFIK_ROOT_DIR/traefik/cert
+
+# Set proper permissions for the certificate storage
+chmod 600 $TRAEFIK_ROOT_DIR/traefik/cert
-# Copy the Caddyfile to the config directory
-cp ./Caddyfile $CADDY_FILE_PATH
-
# Install uv python manager
curl -LsSf https://astral.sh/uv/install.sh | sh
@@ -60,6 +58,9 @@ mkdir -p $HF_CACHE_DIR
# Install HF-CLI and download the model to the cache directory
uv run --with "huggingface_hub[cli]" huggingface-cli login --token $HF_HUB_TOKEN
-uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR
+uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR
+
+# Set MODEL_DIR_PATH environment variable to point to the downloaded model
+export MODEL_DIR_PATH="$HF_CACHE_DIR/models--$(echo $MODEL_NAME | tr '/' '-')/snapshots/$(ls $HF_CACHE_DIR/models--$(echo $MODEL_NAME | tr '/' '-')/snapshots/ | head -1)"
-echo "Setup complete! You can now run 'sudo docker compose up -d'"
\ No newline at end of file
+echo "Setup complete! You can now run 'sudo docker compose up -d'"
diff --git a/vllm/test.sh b/vllm/test.sh
index e2494de..3a1f0c7 100644
--- a/vllm/test.sh
+++ b/vllm/test.sh
@@ -2,7 +2,7 @@ curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${VLLM_SERVER_API_KEY}" \
-d '{
- "model": "google/gemma-3-4b-it",
+ "model": "${MODEL_NAME}",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"}