From 765809ac2143187be74dabf348cf1f08549a38c2 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:55:31 +0200 Subject: [PATCH 01/10] test: update tests.yml --- .github/workflows/tests.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c6f62eb..621781a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,26 +1,31 @@ name: Tests on: - pull_request: push: - branches: - - main + branches: [main, dev] + pull_request: + branches: [main] jobs: - non-llm-tests: + test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v4 + with: + version: "latest" - - name: Set up Python - run: uv python install 3.11 + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install dependencies run: uv sync --extra dev - name: Run non-LLM tests - run: uv run pytest -q -m "not llm" \ No newline at end of file + run: uv run pytest -q -m "not llm" --tb=short From 2c3874d9010aef47735f2c5df42fc763b8249564 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:55:59 +0200 Subject: [PATCH 02/10] test: update pyproject.toml with pytest call --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c440c76..dbf33e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,13 @@ authors = [ { name = "Nhat Quang Dang", email = "dangnhatquang199@gmail.com" }, ] requires-python = ">=3.10,<3.14" -dependencies = ["crewai[tools]==1.14.4", "pyyaml>=6.0", "python-dotenv>=1.0.0"] + +dependencies = [ + "crewai[tools]==1.14.4", + "pyyaml>=6.0", + "python-dotenv>=1.0.0", + "ollama>=0.6.2", +] [project.optional-dependencies] dev = ["pytest>=8.0.0", "pytest-cov>=5.0.0"] @@ -20,6 +26,8 @@ train = "bandai.main:train" replay = "bandai.main:replay" test = "bandai.main:test" run_with_trigger = "bandai.main:run_with_trigger" +kickoff = "bandai.main:run" +pytest_unit = "bandai.main:run_pytest" [build-system] requires = ["hatchling"] From 261cac41edfd07c5a065b4d7c44f6b1e00d2a7d7 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:56:15 +0200 Subject: [PATCH 03/10] test: update main.py with pytest function --- src/bandai/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bandai/main.py b/src/bandai/main.py index 3f29926..604e668 100644 --- a/src/bandai/main.py +++ b/src/bandai/main.py @@ -134,6 +134,11 @@ def test() -> None: _run_command(["crewai", "test", *sys.argv[1:]]) +def run_pytest() -> None: + """Run unit tests (pytest) via the project script uv run pytest_unit.""" + _run_command(["pytest", "tests/", "-v", *sys.argv[1:]]) + + def run_with_trigger() -> None: """Run the BandAI pipeline from an external trigger (webhook/API).""" run() From 59e38066faaed25851c7293e48eb682446357ff9 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:56:24 +0200 Subject: [PATCH 04/10] test: add test_config.py --- tests/test_config.py | 746 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 746 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5a0c04f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,746 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +_mod_ec = types.ModuleType("crewai.rag.embeddings.providers.custom.embedding_callable") + + +class _MockEmbeddingFunction: + pass + + +_mod_ec.CustomEmbeddingFunction = _MockEmbeddingFunction +sys.modules.setdefault( + "crewai.rag.embeddings.providers.custom", + types.ModuleType("crewai.rag.embeddings.providers.custom"), +) +sys.modules["crewai.rag.embeddings.providers.custom.embedding_callable"] = _mod_ec + +_mod_cp = types.ModuleType("crewai.rag.embeddings.providers.custom.custom_provider") + + +class _MockCustomProvider: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +_mod_cp.CustomProvider = _MockCustomProvider +sys.modules["crewai.rag.embeddings.providers.custom.custom_provider"] = _mod_cp + +# Mock: crewai.knowledge.source.string_knowledge_source.StringKnowledgeSource +# knowledge_sources.py imports this at module level. +_mod_sks = types.ModuleType("crewai.knowledge.source.string_knowledge_source") + + +class _MockStringKnowledgeSource: + def __init__(self, *args, **kwargs): + self.content = kwargs.get("content", "") + + # Allow in checks on the content string + def __contains__(self, item): + return item in self.content if isinstance(self.content, str) else False + + +_mod_sks.StringKnowledgeSource = _MockStringKnowledgeSource +sys.modules.setdefault("crewai.knowledge.source", types.ModuleType("crewai.knowledge.source")) +sys.modules["crewai.knowledge.source.string_knowledge_source"] = _mod_sks +sys.modules.setdefault("crewai.knowledge", types.ModuleType("crewai.knowledge")) +sys.modules["crewai.knowledge"].source = sys.modules["crewai.knowledge.source"] +if "crewai" in sys.modules: + sys.modules["crewai"].knowledge = sys.modules["crewai.knowledge"] + +# Knowledge model tests + + +class TestKnowledgeModels: + """Tests for CompanyProfile, DepartmentProfile, and PastContract models.""" + + def test_valid_company_profile(self) -> None: + from bandai.models.knowledge import CompanyProfile + + data = { + "name": "TestCo", + "vat_number": "IT00000000000", + "ateco_codes": ["62.01.09"], + "certifications": ["ISO 9001:2015"], + "turnover_last_3y_eur": [1000000.0, 1200000.0], + "employees": 10, + "max_bid_value_eur": 500000.0, + "past_public_contracts": [ + { + "title": "Test Contract", + "value_eur": 50000.0, + "cpv_codes": ["72000000"], + "year": 2023, + "authority": "Comune di Test", + "topics": ["test"], + } + ], + "departments": { + "Engineering": { + "capabilities": ["Software dev"], + "certifications": ["ISO 9001"], + "case_studies": [], + "kpis": {"on_time": 95}, + } + }, + } + profile = CompanyProfile.model_validate(data) + assert profile.name == "TestCo" + assert len(profile.departments) == 1 + assert len(profile.past_public_contracts) == 1 + assert profile.department_names == ["Engineering"] + + def test_company_profile_missing_required_field(self) -> None: + from pydantic import ValidationError + from bandai.models.knowledge import CompanyProfile + + with pytest.raises(ValidationError): + CompanyProfile.model_validate({"name": "TestCo"}) + + def test_parse_company_profile_from_dict(self) -> None: + """parse_company_profile accepts a dict of company data.""" + from bandai.models.knowledge import parse_company_profile + + data = { + "name": "FileCo", + "vat_number": "IT11111111111", + "ateco_codes": ["62.01.09"], + "certifications": [], + "turnover_last_3y_eur": [500000.0], + "employees": 5, + "max_bid_value_eur": 200000.0, + "past_public_contracts": [], + "departments": {}, + } + profile = parse_company_profile(data) + assert profile.name == "FileCo" + assert profile.employees == 5 + + def test_load_company_profile_default_uses_file(self) -> None: + """load_company_profile() loads from the knowledge JSON file.""" + from bandai.models.knowledge import load_company_profile + + profile = load_company_profile() + assert profile.name + assert isinstance(profile.ateco_codes, list) + + def test_parse_company_profile_invalid_data(self) -> None: + """parse_company_profile raises ValueError on invalid data.""" + from bandai.models.knowledge import parse_company_profile + + with pytest.raises(ValueError, match="Invalid company profile data"): + parse_company_profile({"name": "TestCo"}) + + def test_load_company_profile_missing_file(self) -> None: + """load_company_profile() raises FileNotFoundError if JSON missing.""" + from bandai.models.knowledge import load_company_profile, clear_profile_cache + from unittest.mock import patch as mock_patch + + clear_profile_cache() + with mock_patch( + "bandai.knowledge_sources._COMPANY_PROFILE_PATH", + Path("/nonexistent/knowledge/company_profile.json"), + ): + with pytest.raises(FileNotFoundError, match="Knowledge source not found"): + load_company_profile() + clear_profile_cache() + + +# Provider config tests + + +class TestProviderConfig: + """Tests for multi-provider LLM configuration.""" + + def setup_method(self) -> None: + """Clear LLM caches before each test.""" + from bandai.config import clear_caches + + clear_caches() + + def test_builtin_providers_exist(self) -> None: + from bandai.config import PROVIDERS + + assert "openrouter" in PROVIDERS + assert "anthropic" in PROVIDERS + assert "openai" in PROVIDERS + assert "ollama" in PROVIDERS + assert len(PROVIDERS) >= 4 + + def test_provider_profile_structure(self) -> None: + from bandai.config import ProviderProfile, LLMProfile + + provider = ProviderProfile( + name="test", + base_url="https://test.example.com/v1", + main=LLMProfile(model="large", temperature=0.5, max_tokens=2048), + fast=LLMProfile(model="small", temperature=0.3, max_tokens=1024), + ) + assert provider.main_model == "test/large" + assert provider.fast_model == "test/small" + assert provider.env_key == "API_KEY" + + def test_llm_profile_temperature_bounds(self) -> None: + from pydantic import ValidationError + from bandai.config import LLMProfile + + with pytest.raises(ValidationError): + LLMProfile(model="test", temperature=5.0) + + with pytest.raises(ValidationError): + LLMProfile(model="test", temperature=-1.0) + + def test_get_active_provider_default(self) -> None: + from bandai.config import get_active_provider, clear_caches + + clear_caches() + with patch.dict(os.environ, {"LLM_PROVIDER": ""}, clear=False): + with patch.dict(os.environ, {"LLM_PROVIDER": "openrouter"}): + provider = get_active_provider() + assert provider.name == "openrouter" + + def test_get_active_provider_custom(self) -> None: + from bandai.config import get_active_provider, clear_caches + + clear_caches() + with patch.dict(os.environ, {"LLM_PROVIDER": "ollama"}): + provider = get_active_provider() + assert provider.name == "ollama" + + def test_get_active_provider_unknown(self) -> None: + from bandai.config import get_active_provider, clear_caches + + clear_caches() + with patch.dict(os.environ, {"LLM_PROVIDER": "nonexistent_provider"}): + with pytest.raises(ValueError, match="Unknown LLM_PROVIDER"): + get_active_provider() + + def test_env_overrides_model(self) -> None: + from bandai.config import EnvOverrides, clear_caches + + clear_caches() + with patch.dict(os.environ, {"MAIN_MODEL": "anthropic/claude-3"}): + overrides = EnvOverrides.from_env("MAIN_") + assert overrides.model == "anthropic/claude-3" + + def test_env_overrides_none(self) -> None: + from bandai.config import EnvOverrides, clear_caches + + clear_caches() + with patch.dict(os.environ, {}, clear=True): + overrides = EnvOverrides.from_env("FAST_") + assert overrides.model is None + assert overrides.temperature is None + + +# Config validation tests + + +class TestConfigValidation: + """Tests for the validate_config() startup check.""" + + def test_validate_config_all_good(self) -> None: + from bandai.config import validate_config, clear_caches + + clear_caches() + project_root = Path(__file__).parent.parent + knowledge_p = project_root / "knowledge" / "company_profile.json" + portal_p = project_root / "src" / "bandai" / "config" / "portals.yaml" + + assert knowledge_p.exists(), f"Test prerequisite: {knowledge_p} must exist" + assert portal_p.exists(), f"Test prerequisite: {portal_p} must exist" + + env = {"LLM_PROVIDER": "openrouter", "OPENROUTER_API_KEY": "sk-test-key"} + + with patch.dict(os.environ, env, clear=False): + errors = validate_config() + if errors: + for e in errors: + print(f" UNEXPECTED ERROR: {e}") + assert errors == [] + + def test_validate_config_missing_api_key(self) -> None: + from bandai.config import validate_config, clear_caches + + clear_caches() + env = { + "LLM_PROVIDER": "openrouter", + "API_KEY": "your_api_key_here", + "OPENROUTER_API_KEY": "", + } + + with patch.dict(os.environ, env, clear=True): + errors = validate_config() + assert len(errors) >= 1 + assert any("API key" in e for e in errors) + + def test_validate_config_unknown_provider(self) -> None: + from bandai.config import validate_config, clear_caches + + clear_caches() + with patch.dict(os.environ, {"LLM_PROVIDER": "nonexistent"}): + errors = validate_config() + assert any("Unknown provider" in e for e in errors) + + +# Portal config tests + + +class TestPortalConfig: + """Tests for portal YAML loading.""" + + def test_portal_config_valid(self) -> None: + from bandai.config.portals import PortalConfig + + data = { + "name": "Test Portal", + "base_url": "https://test.example.com", + "reliability": 0.85, + "country_filter": "IT", + } + portal = PortalConfig.model_validate(data) + assert portal.name == "Test Portal" + assert portal.reliability == 0.85 + + def test_portal_config_reliability_bounds(self) -> None: + from pydantic import ValidationError + from bandai.config.portals import PortalConfig + + with pytest.raises(ValidationError): + PortalConfig.model_validate( + { + "name": "Bad", + "base_url": "https://x.com", + "reliability": 1.5, + } + ) + + def test_portal_config_load_from_yaml(self) -> None: + from bandai.config.portals import _load_portals + + yaml_content = { + "portals": [ + {"name": "Portal A", "base_url": "https://a.com", "reliability": 0.9}, + { + "name": "Portal B", + "base_url": "https://b.com", + "reliability": 0.7, + "country_filter": "DE", + }, + ] + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as f: + yaml.dump(yaml_content, f) + tmp_path = Path(f.name) + + try: + portals = _load_portals(tmp_path) + assert len(portals) == 2 + assert portals[0].name == "Portal A" + assert portals[1].country_filter == "DE" + finally: + tmp_path.unlink() + + def test_portal_config_missing_file(self) -> None: + from bandai.config.portals import _load_portals + + portals = _load_portals(Path("/nonexistent/portals.yaml")) + assert portals == [] + + def test_portal_weights_computed(self) -> None: + from bandai.config.portals import portal_weight + + w = portal_weight("Nonexistent Portal") + assert 0.0 <= w <= 1.0 + + def test_validate_portals_with_data(self) -> None: + """validate_portals() should not raise when portals are configured.""" + from bandai.config.portals import validate_portals + + # In the test environment, portals.yaml exists, so this should pass. + validate_portals() # Should not raise + + +# Pipeline model tests + + +class TestPipelineModels: + """Tests for Pydantic models used in the pipeline.""" + + def test_compliance_verdict_valid(self) -> None: + from bandai.models.models import ComplianceVerdict + + v = ComplianceVerdict( + bid_decision="GO", + key_risks=["minor risk"], + key_strengths=["strong team"], + compliance_score=0.85, + verdict_rationale="Solid match.", + ) + assert v.bid_decision == "GO" + assert v.conditions == [] + assert v.legal_flags == [] + + def test_compliance_verdict_invalid_decision(self) -> None: + from pydantic import ValidationError + from bandai.models.models import ComplianceVerdict + + with pytest.raises(ValidationError): + ComplianceVerdict( + bid_decision="MAYBE", + key_risks=[], + key_strengths=[], + compliance_score=0.5, + verdict_rationale="test", + ) + + def test_compliance_verdict_score_bounds(self) -> None: + from pydantic import ValidationError + from bandai.models.models import ComplianceVerdict + + with pytest.raises(ValidationError): + ComplianceVerdict( + bid_decision="GO", + key_risks=[], + key_strengths=[], + compliance_score=1.5, + verdict_rationale="test", + ) + + def test_raw_contract_cpv_alias(self) -> None: + from bandai.models.models import RawContract + + c = RawContract( + portal="test", + url="https://test.com", + title="Test", + contracting_authority="Auth", + deadline="2024-12-31", + raw_text="text", + cpvCodes=["72000000"], + ) + assert c.cpv_codes == ["72000000"] + + +# Knowledge sources tests + + +class TestKnowledgeSources: + """Tests for the CrewAI Knowledge source factory.""" + + def test_get_company_knowledge_data_valid(self) -> None: + from bandai.knowledge_sources import get_company_knowledge_data + + data = get_company_knowledge_data() + assert isinstance(data, dict) + assert "name" in data + assert "ateco_codes" in data + + def test_get_company_knowledge_data_missing(self) -> None: + from bandai.knowledge_sources import get_company_knowledge_data + from unittest.mock import patch as mock_patch + + with mock_patch( + "bandai.knowledge_sources._COMPANY_PROFILE_PATH", + Path("/nonexistent/knowledge/company_profile.json"), + ): + with pytest.raises(FileNotFoundError, match="Knowledge source not found"): + get_company_knowledge_data() + + def test_get_company_knowledge_source_valid(self) -> None: + from bandai.knowledge_sources import get_company_knowledge_source + + source = get_company_knowledge_source() + assert source is not None + assert hasattr(source, "content") + assert "name" in source.content # type: ignore[attr-defined] + + def test_get_company_knowledge_source_missing(self) -> None: + from bandai.knowledge_sources import get_company_knowledge_source + from unittest.mock import patch as mock_patch + + with mock_patch( + "bandai.knowledge_sources._COMPANY_PROFILE_PATH", + Path("/nonexistent/knowledge/company_profile.json"), + ): + with pytest.raises(FileNotFoundError, match="Knowledge source not found"): + get_company_knowledge_source() + + def test_get_all_knowledge_sources_returns_list(self) -> None: + from bandai.knowledge_sources import get_all_knowledge_sources + + sources = get_all_knowledge_sources() + assert isinstance(sources, list) + + def test_get_all_knowledge_sources_graceful_missing(self) -> None: + from bandai.knowledge_sources import get_all_knowledge_sources + from unittest.mock import patch as mock_patch + + with mock_patch( + "bandai.knowledge_sources._COMPANY_PROFILE_PATH", + Path("/nonexistent/knowledge/company_profile.json"), + ): + sources = get_all_knowledge_sources() + assert sources == [] + + +# Flow persistence test + + +class TestFlowPersistence: + """Tests for @persist decorator and BandAIFlow state model.""" + + def test_bandai_state_default(self) -> None: + from bandai.flow import BandAIState + + state = BandAIState() + assert state.user_preferences == "" + assert state.mode == "full" + assert state.contracts == [] + assert state.approved_contracts == [] + assert state.proposals == [] + assert state.total_contracts == 0 + + def test_bandai_state_custom_mode(self) -> None: + from bandai.flow import BandAIState + + state = BandAIState(mode="scout") + assert state.mode == "scout" + + def test_bandai_state_serializable(self) -> None: + import json as _json + from bandai.flow import BandAIState + + state = BandAIState( + mode="full", + contracts=[{"title": "Test", "value_eur": 1000}], + approved_contracts=[ + ( + {"title": "T"}, + { + "bid_decision": "GO", + "key_risks": [], + "key_strengths": [], + "compliance_score": 0.9, + "verdict_rationale": "ok", + }, + ) + ], + ) + dumped = state.model_dump_json() + parsed = _json.loads(dumped) + assert parsed["mode"] == "full" + assert len(parsed["contracts"]) == 1 + + def test_bandai_flow_structure(self) -> None: + from bandai.flow import BandAIFlow + from crewai.flow.flow import Flow # type: ignore[import] + + assert issubclass(BandAIFlow, Flow) + for method_name in [ + "begin", + "route_from_begin", + "run_scouting", + "route_after_scout", + "end_scout_only", + "skip_to_compliance", + "route_skip", + "init_compliance", + "route_after_init", + "process_next_contract_func", + "route_process_contract", + "run_compliance_crew_func", + "route_verdict", + "handle_conditional_go_func", + "route_after_conditional", + "after_compliance", + "route_after_compliance", + "run_proposals", + ]: + assert hasattr(BandAIFlow, method_name), f"BandAIFlow missing method: {method_name}" + + +# IO tests + + +class TestIO: + """Tests for bandai.io output utilities.""" + + def test_output_dir_exists(self) -> None: + from bandai.io import OUTPUT_DIR + + assert OUTPUT_DIR.exists() + assert OUTPUT_DIR.is_dir() + + def test_save_json(self) -> None: + from bandai.io import save_json + + path = save_json({"test": True}, "_test_output.json") + assert path.exists() + content = path.read_text(encoding="utf-8") + assert "test" in content + path.unlink() + + def test_save_json_nested(self) -> None: + from bandai.io import save_json + + path = save_json( + {"contracts": [{"id": 1}]}, + "_test_nested.json", + ) + assert path.exists() + path.unlink() + + +# Reload portals tests + + +class TestReloadPortals: + """Tests for reload_portals() reloading from a new YAML file.""" + + def test_reload_updates_portal_weights(self) -> None: + import bandai.config.portals as portals_mod + + yaml_content = { + "portals": [ + {"name": "Reload Portal A", "base_url": "https://reload-a.com", "reliability": 0.99}, + {"name": "Reload Portal B", "base_url": "https://reload-b.com", "reliability": 0.88}, + ] + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as f: + yaml.dump(yaml_content, f) + tmp_path = Path(f.name) + + # Save original module-level state so we can restore it after the test + original_bandi_portals = portals_mod.BANDI_PORTALS + original_portal_weights = portals_mod.PORTAL_WEIGHTS + + try: + # Patch _load_portals to read from our temp file + with patch.object( + portals_mod, + "_load_portals", + return_value=portals_mod._load_portals(tmp_path), + ): + result = portals_mod.reload_portals() + + assert len(result) == 2 + assert result[0].name == "Reload Portal A" + # Access PORTAL_WEIGHTS through the module reference (not a local + # binding) because reload_portals() replaces the global dict. + assert portals_mod.PORTAL_WEIGHTS.get("Reload Portal A") == 0.99 + assert portals_mod.PORTAL_WEIGHTS.get("Reload Portal B") == 0.88 + finally: + tmp_path.unlink() + portals_mod.BANDI_PORTALS = original_bandi_portals + portals_mod.PORTAL_WEIGHTS = original_portal_weights + + +# Validate portals - no portals configured + + +class TestValidatePortalsNoPortals: + """When BANDI_PORTALS is empty, validate_portals raises ValueError.""" + + def test_raises_when_no_portals(self) -> None: + import bandai.config.portals as portals_mod + from bandai.config.portals import validate_portals + + original_portals = portals_mod.BANDI_PORTALS + + try: + portals_mod.BANDI_PORTALS = [] + with pytest.raises(ValueError, match="No procurement portals configured"): + validate_portals() + finally: + portals_mod.BANDI_PORTALS = original_portals + + +# LLM factory tests + + +class TestLLMFactory: + """Tests for get_llm factory with mocked CrewAI LLM.""" + + def setup_method(self) -> None: + """Clear LLM caches before each test.""" + from bandai.config import clear_caches + + clear_caches() + + def teardown_method(self) -> None: + """Clear caches after each test.""" + from bandai.config import clear_caches + + clear_caches() + + def _install_mock_crewai(self) -> MagicMock: + """Install a mock crewai package in sys.modules with an LLM class. + + get_llm() does a lazy from crewai import LLM inside the + function body, so we need the mock at the sys.modules level. + Returns the mock LLM class for further assertions. + """ + mock_crewai = types.ModuleType("crewai") + mock_llm_class = MagicMock(return_value=MagicMock()) + mock_crewai.LLM = mock_llm_class + sys.modules["crewai"] = mock_crewai + return mock_llm_class + + def _remove_mock_crewai(self) -> None: + """Remove the mock crewai package from sys.modules.""" + sys.modules.pop("crewai", None) + + def test_get_llm_caches_results(self) -> None: + """Verify that get_llm returns the same instance for the same args (lru_cache).""" + from bandai.config.llm import get_llm + + get_llm.cache_clear() + mock_llm_class = self._install_mock_crewai() + + try: + with patch.dict(os.environ, {"LLM_PROVIDER": "ollama"}, clear=False): + llm1 = get_llm(fast=False) + llm2 = get_llm(fast=False) + + # lru_cache should return the same object + assert llm1 is llm2 + mock_llm_class.assert_called_once() + finally: + get_llm.cache_clear() + self._remove_mock_crewai() + + def test_get_llm_env_override_main_model(self) -> None: + """Verify that MAIN_MODEL env var overrides the provider default.""" + from bandai.config.llm import get_llm + + get_llm.cache_clear() + mock_llm_class = self._install_mock_crewai() + + try: + env = { + "LLM_PROVIDER": "ollama", + "MAIN_MODEL": "ollama/custom-model", + } + with patch.dict(os.environ, env, clear=False): + llm = get_llm(fast=False) + + mock_llm_class.assert_called_once() + call_kwargs = mock_llm_class.call_args[1] + # The model should contain our override + assert "custom-model" in call_kwargs["model"] + finally: + get_llm.cache_clear() + self._remove_mock_crewai() From f6440418b9ea057b2afe8382052cc3e693dbb298 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:56:35 +0200 Subject: [PATCH 05/10] test: add test_embedder.py --- tests/test_embedder.py | 214 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/test_embedder.py diff --git a/tests/test_embedder.py b/tests/test_embedder.py new file mode 100644 index 0000000..e43e324 --- /dev/null +++ b/tests/test_embedder.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import sys +import types +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +# Mock: crewai.rag.embeddings.providers.custom.embedding_callable +_mod_ec = types.ModuleType("crewai.rag.embeddings.providers.custom.embedding_callable") + + +class _MockEmbeddingFunction: + pass + + +_mod_ec.CustomEmbeddingFunction = _MockEmbeddingFunction +sys.modules.setdefault( + "crewai.rag.embeddings.providers.custom", + types.ModuleType("crewai.rag.embeddings.providers.custom"), +) +sys.modules["crewai.rag.embeddings.providers.custom.embedding_callable"] = _mod_ec + +# Mock: crewai.rag.embeddings.providers.custom.custom_provider +_mod_cp = types.ModuleType("crewai.rag.embeddings.providers.custom.custom_provider") + + +class _MockCustomProvider: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +_mod_cp.CustomProvider = _MockCustomProvider +sys.modules["crewai.rag.embeddings.providers.custom.custom_provider"] = _mod_cp + +from bandai.config.embedder import OpenRouterEmbeddingFunction # noqa: E402 + + +class TestVLModelDetection: + """Verify that model names containing vision-language keywords are flagged.""" + + @pytest.mark.parametrize( + "model_name", + [ + "nvidia/llama-nemotron-embed-vl-1b-v2:free", + "some-vl-model", + "openai/vision-embed", + "nemo-vl-large", + "vila-v2", + ], + ) + def test_vl_keywords_detected(self, model_name: str) -> None: + emb = OpenRouterEmbeddingFunction(model_name=model_name) + assert emb._is_vl is True + + @pytest.mark.parametrize( + "model_name", + [ + "text-embedding-3-large", + "nomic-embed-text:latest", + "bge-large-en-v1.5", + "sentence-transformers/all-MiniLM-L6-v2", + ], + ) + def test_non_vl_keywords_not_detected(self, model_name: str) -> None: + emb = OpenRouterEmbeddingFunction(model_name=model_name) + assert emb._is_vl is False + + +class TestBuildInputVL: + """Verify _build_input wraps text in content array format for VL models.""" + + def test_wraps_texts(self) -> None: + emb = OpenRouterEmbeddingFunction(model_name="nvidia/llama-nemotron-embed-vl-1b-v2:free") + result = emb._build_input(["hello", "world"]) + assert isinstance(result, list) + assert len(result) == 2 + assert "content" in result[0] + assert isinstance(result[0]["content"], list) + assert result[0]["content"][0]["type"] == "text" + assert result[0]["content"][0]["text"] == "hello" + + +class TestBuildInputNonVL: + """Verify _build_input returns plain list for non-VL models.""" + + def test_plain_list(self) -> None: + emb = OpenRouterEmbeddingFunction(model_name="text-embedding-3-large") + texts = ["hello", "world"] + result = emb._build_input(texts) + assert result is texts # Should return the same list object + assert result == ["hello", "world"] + + +class TestName: + """Verify the static name() identifier.""" + + def test_returns_openrouter(self) -> None: + assert OpenRouterEmbeddingFunction.name() == "openrouter" + + +class TestCallEmptyInput: + """Verify __call__ returns [] for empty input.""" + + def test_empty_list(self) -> None: + emb = OpenRouterEmbeddingFunction() + result = emb([]) + assert result == [] + + +class TestCallSuccess: + """Verify successful embedding call returns list of np.ndarray.""" + + @patch("bandai.config.embedder.requests.post") + def test_returns_ndarray_list(self, mock_post: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"embedding": [0.1, 0.2, 0.3]}, + {"embedding": [0.4, 0.5, 0.6]}, + ] + } + mock_post.return_value = mock_response + + emb = OpenRouterEmbeddingFunction(model_name="test/model", api_key="sk-test") + result = emb(["hello", "world"]) + + assert len(result) == 2 + assert isinstance(result[0], np.ndarray) + assert result[0].dtype == np.float32 + np.testing.assert_array_almost_equal(result[0], np.array([0.1, 0.2, 0.3], dtype=np.float32)) + np.testing.assert_array_almost_equal(result[1], np.array([0.4, 0.5, 0.6], dtype=np.float32)) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "/embeddings" in call_kwargs[0][0] + + +class TestCallNoDataError: + """Verify ValueError is raised when API returns no embedding data.""" + + @patch("bandai.config.embedder.requests.post") + def test_raises_value_error(self, mock_post: MagicMock) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + mock_post.return_value = mock_response + + emb = OpenRouterEmbeddingFunction(model_name="test/model", api_key="sk-test") + with pytest.raises(ValueError, match="no embedding data"): + emb(["hello"]) + + +class TestGetEmbedderNoProvider: + """When EMBEDDER_PROVIDER is not set, returns None.""" + + def test_returns_none(self) -> None: + from bandai.config.embedder import get_embedder + + get_embedder.cache_clear() + with patch.dict("os.environ", {"EMBEDDER_PROVIDER": ""}, clear=False): + # Need to remove the env var entirely for the code path + with patch("bandai.config.embedder.os.getenv", return_value=None): + result = get_embedder() + assert result is None + get_embedder.cache_clear() + + +class TestGetEmbedderOpenRouter: + """When EMBEDDER_PROVIDER=openrouter, returns a CustomProvider-like object.""" + + def test_returns_custom_provider_object(self) -> None: + from bandai.config.embedder import get_embedder + + get_embedder.cache_clear() + env = { + "EMBEDDER_PROVIDER": "openrouter", + "EMBEDDER_MODEL": "test/vl-model", + "EMBEDDER_API_KEY": "sk-test", + "OPENROUTER_API_KEY": "sk-test", + } + with patch.dict("os.environ", env, clear=False): + result = get_embedder() + + assert result is not None + assert hasattr(result, "embedding_callable") + assert result.embedding_callable is OpenRouterEmbeddingFunction + assert result.model_name == "test/vl-model" + get_embedder.cache_clear() + + +class TestGetEmbedderOllama: + """When EMBEDDER_PROVIDER=ollama, returns a dict with correct structure.""" + + def test_returns_dict(self) -> None: + from bandai.config.embedder import get_embedder + + get_embedder.cache_clear() + env = { + "EMBEDDER_PROVIDER": "ollama", + "EMBEDDER_MODEL": "nomic-embed-text:latest", + "EMBEDDER_BASE_URL": "http://localhost:11434", + } + with patch.dict("os.environ", env, clear=False): + result = get_embedder() + + assert isinstance(result, dict) + assert result["provider"] == "ollama" + assert result["config"]["model_name"] == "nomic-embed-text:latest" + assert result["config"]["url"] == "http://localhost:11434" + get_embedder.cache_clear() From ef11663e28e287490c21c25432400381a8352e6d Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:56:47 +0200 Subject: [PATCH 06/10] test: add test_flow_mocked.py --- tests/test_flow_mocked.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_flow_mocked.py b/tests/test_flow_mocked.py index 89fd28f..88ce9e7 100644 --- a/tests/test_flow_mocked.py +++ b/tests/test_flow_mocked.py @@ -36,8 +36,17 @@ def _install_import_stubs_when_crewai_is_unavailable() -> None: class Flow: @classmethod - def __class_getitem__(cls, _item: object) -> type["Flow"]: - return cls + def __class_getitem__(cls, state_cls: object) -> type["Flow"]: + """Return a subclass whose __init__ creates self.state from state_cls.""" + + class _TypedFlow(cls): # type: ignore[misc] + def __init__(self_inner, *_args: object, **_kwargs: object) -> None: + # Instantiate the state model so self.state is available. + self_inner.state = state_cls() if isinstance(state_cls, type) else state_cls + + _TypedFlow.__name__ = "Flow" + _TypedFlow.__qualname__ = "Flow" + return _TypedFlow def __init__(self, *_args: object, **_kwargs: object) -> None: pass @@ -64,6 +73,8 @@ class StringKnowledgeSource: def __init__(self, *args: object, **kwargs: object) -> None: self.args = args self.kwargs = kwargs + # Expose content kwarg as attribute for tests that check it. + self.content = kwargs.get("content", "") string_knowledge_module.StringKnowledgeSource = StringKnowledgeSource sys.modules["crewai.knowledge"] = knowledge_package From c50e1c102a31216e02f5d59a417479dc52e22a4f Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:56:58 +0200 Subject: [PATCH 07/10] test: add test_guardrails.py --- tests/test_guardrails.py | 148 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/test_guardrails.py diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..0e555c0 --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import sys +import types +from unittest.mock import MagicMock, PropertyMock + +if "crewai" not in sys.modules: + _crewai = types.ModuleType("crewai") + _crewai.TaskOutput = MagicMock(name="TaskOutput") + sys.modules["crewai"] = _crewai +else: + if not hasattr(sys.modules["crewai"], "TaskOutput"): + sys.modules["crewai"].TaskOutput = MagicMock(name="TaskOutput") + +from bandai.guardrails import validate_json_array, validate_compliance_verdict # noqa: E402 + + +def _make_task_output(raw: str) -> MagicMock: + """Create a mock TaskOutput with a given raw string.""" + out = MagicMock() + out.raw = raw + return out + + +class TestValidateJsonArray: + """Tests for validate_json_array().""" + + def test_valid_array(self) -> None: + result = _make_task_output('[{"id": 1}]') + ok, msg = validate_json_array(result) + assert ok is True + + def test_empty_array(self) -> None: + result = _make_task_output("[]") + ok, msg = validate_json_array(result) + assert ok is True + + def test_with_markdown_fences(self) -> None: + result = _make_task_output('```json\n[{"id": 1}]\n```') + ok, msg = validate_json_array(result, strip_fences=True) + assert ok is True + + def test_fences_extracted_by_regex_fallback(self) -> None: + result = _make_task_output('```json\n[{"id": 1}]\n```') + ok, msg = validate_json_array(result, strip_fences=False) + assert ok is True + assert msg.startswith("[") + assert msg.endswith("]") + + def test_plain_text_rejected(self) -> None: + result = _make_task_output("Here is my analysis...") + ok, msg = validate_json_array(result) + assert ok is False + assert "JSON array" in msg + + def test_single_object_rejected(self) -> None: + result = _make_task_output('{"id": 1}') + ok, msg = validate_json_array(result) + assert ok is False + assert "JSON array (list)" in msg + + def test_invalid_json_rejected(self) -> None: + result = _make_task_output("[not, valid, json") + ok, msg = validate_json_array(result) + assert ok is False + assert "not valid JSON" in msg + + def test_array_embedded_in_verbose_text(self) -> None: + raw = "To answer the prompt, we need to call the crawler tool.\n\n" "Here is the result:\n\n" "```json\n" '[{"id": 1, "title": "Test"}]\n' "```\n" + result = _make_task_output(raw) + ok, msg = validate_json_array(result) + assert ok is True + + def test_array_embedded_without_fences(self) -> None: + raw = "The final answer is:\n\n" '[{"canonical_contract_id": "001", "title": "Tender"}]' + result = _make_task_output(raw) + ok, msg = validate_json_array(result) + assert ok is True + + def test_strip_fences_returns_clean_json(self) -> None: + result = _make_task_output('```json\n[{"id": 1}]\n```') + ok, msg = validate_json_array(result, strip_fences=True) + assert ok is True + assert "```" not in msg + + +class TestValidateComplianceVerdict: + """Tests for validate_compliance_verdict().""" + + def _make_verdict_output(self, **overrides) -> MagicMock: + verdict_data = { + "bid_decision": "GO", + "key_risks": [], + "key_strengths": ["Strong team"], + "compliance_score": 0.85, + "verdict_rationale": "Good match.", + "conditions": [], + "legal_flags": [], + } + verdict_data.update(overrides) + + mock_verdict = MagicMock() + mock_verdict.bid_decision = verdict_data["bid_decision"] + mock_verdict.compliance_score = verdict_data["compliance_score"] + + out = MagicMock() + out.pydantic = mock_verdict + return out + + def test_valid_go(self) -> None: + result = self._make_verdict_output() + ok, msg = validate_compliance_verdict(result) + assert ok is True + + def test_valid_no_go(self) -> None: + result = self._make_verdict_output(bid_decision="NO-GO") + ok, msg = validate_compliance_verdict(result) + assert ok is True + + def test_valid_conditional_go(self) -> None: + result = self._make_verdict_output(bid_decision="CONDITIONAL-GO") + ok, msg = validate_compliance_verdict(result) + assert ok is True + + def test_invalid_decision(self) -> None: + result = self._make_verdict_output(bid_decision="MAYBE") + ok, msg = validate_compliance_verdict(result) + assert ok is False + assert "bid_decision" in msg + + def test_score_too_high(self) -> None: + result = self._make_verdict_output(compliance_score=1.5) + ok, msg = validate_compliance_verdict(result) + assert ok is False + assert "compliance_score" in msg + + def test_score_too_low(self) -> None: + result = self._make_verdict_output(compliance_score=-0.1) + ok, msg = validate_compliance_verdict(result) + assert ok is False + assert "compliance_score" in msg + + def test_unparseable_output(self) -> None: + out = MagicMock() + type(out).pydantic = PropertyMock(side_effect=Exception("bad")) + ok, msg = validate_compliance_verdict(out) + assert ok is False + assert "Could not parse" in msg From 45d09f15f3e40fee4672cde2867f1968748ffc92 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:57:09 +0200 Subject: [PATCH 08/10] test: add test_memory.py --- tests/test_memory.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_memory.py diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..3e0a2dd --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest + +_mod_ec = types.ModuleType("crewai.rag.embeddings.providers.custom.embedding_callable") + + +class _MockEmbeddingFunction: + pass + + +_mod_ec.CustomEmbeddingFunction = _MockEmbeddingFunction +sys.modules.setdefault( + "crewai.rag.embeddings.providers.custom", + types.ModuleType("crewai.rag.embeddings.providers.custom"), +) +sys.modules["crewai.rag.embeddings.providers.custom.embedding_callable"] = _mod_ec + +_mod_cp = types.ModuleType("crewai.rag.embeddings.providers.custom.custom_provider") + + +class _MockCustomProvider: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +_mod_cp.CustomProvider = _MockCustomProvider +sys.modules["crewai.rag.embeddings.providers.custom.custom_provider"] = _mod_cp + + +_mock_memory_module = types.ModuleType("crewai.memory.unified_memory") + + +class _FakeMemory: + def __init__(self, llm=None, embedder=None): + self.llm = llm + self.embedder = embedder + + +_mock_memory_module.Memory = _FakeMemory +sys.modules["crewai.memory"] = types.ModuleType("crewai.memory") +sys.modules["crewai.memory.unified_memory"] = _mock_memory_module + + +class TestGetMemoryDisabled: + """When DISABLE_MEMORY is set to a truthy value, returns False.""" + + @pytest.mark.parametrize("value", ["true", "True", "TRUE", "1", "yes", "Yes", "YES"]) + def test_returns_false(self, value: str) -> None: + from bandai.config.memory import get_memory + + with patch.dict("os.environ", {"DISABLE_MEMORY": value}, clear=False): + result = get_memory() + assert result is False + + +class TestGetMemoryEnabledMockedCrewAI: + """With DISABLE_MEMORY not set, verify Memory is created with correct args.""" + + def test_creates_memory_with_llm_and_embedder(self) -> None: + from bandai.config.memory import get_memory + + fake_llm = MagicMock(name="fake-llm") + fake_embedder = MagicMock(name="fake-embedder") + + with patch.dict("os.environ", {"DISABLE_MEMORY": "false"}, clear=False): + with patch("bandai.config.llm.get_llm", return_value=fake_llm) as mock_get_llm: + with patch("bandai.config.embedder.get_embedder", return_value=fake_embedder) as mock_get_embedder: + result = get_memory() + + assert isinstance(result, _FakeMemory) + assert result.llm is fake_llm + assert result.embedder is fake_embedder + mock_get_llm.assert_called_once_with(fast=True) + mock_get_embedder.assert_called_once() From 983dc4109a8f73d388f85c7cc2643039a41f4ed1 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 22:57:25 +0200 Subject: [PATCH 09/10] test: add test_utils.py --- tests/test_utils.py | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..fed6a92 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import pytest + +from bandai.utils import ( + extract_json_array, + is_implicit_no_go, + load_yaml_config, + contract_to_summary, +) + + +class TestExtractJsonArray: + """Tests for extract_json_array().""" + + def test_valid_array(self) -> None: + raw = '[{"a": 1}, {"b": 2}]' + result = extract_json_array(raw) + assert isinstance(result, list) + assert len(result) == 2 + + def test_array_with_surrounding_text(self) -> None: + raw = 'Here is the result:\n[{"id": 1}]\nDone.' + result = extract_json_array(raw) + assert len(result) == 1 + assert result[0]["id"] == 1 + + def test_multiline_array(self) -> None: + raw = '[\n {"a": 1},\n {"b": 2}\n]' + result = extract_json_array(raw) + assert len(result) == 2 + + def test_no_array_raises(self) -> None: + with pytest.raises(ValueError, match="No JSON array"): + extract_json_array("no array here") + + def test_invalid_json_raises(self) -> None: + with pytest.raises(json.JSONDecodeError): + extract_json_array("[not valid json") + + def test_empty_array(self) -> None: + result = extract_json_array("[]") + assert result == [] + + +class TestIsImplicitNoGo: + """Tests for is_implicit_no_go().""" + + def test_italian_no_go(self) -> None: + assert is_implicit_no_go("Non abbiamo i requisiti") + assert is_implicit_no_go("impossibile completare") + assert is_implicit_no_go("rinunciamo a questo bando") + + def test_english_no_go(self) -> None: + assert is_implicit_no_go("we can't meet the deadline") + assert is_implicit_no_go("we don't have the certification") + + def test_positive_intent(self) -> None: + assert not is_implicit_no_go("We have all certifications") + assert not is_implicit_no_go("Posiamo procedere con la documentazione") + + def test_empty_string(self) -> None: + assert not is_implicit_no_go("") + assert not is_implicit_no_go(" ") + + def test_case_insensitive(self) -> None: + assert is_implicit_no_go("IMPOSSIBILE") + assert is_implicit_no_go("Non Abbiamo") + + +class TestContractToSummary: + """Tests for contract_to_summary().""" + + def test_full_contract(self) -> None: + c = { + "title": "Test Tender", + "canonical_contract_id": "TC-001", + "contracting_authority": "Comune di Roma", + "value_eur": 150000, + "deadline": "2025-12-31", + "cpv_codes": ["72000000", "72212517"], + "canonical_url": "https://example.com/tender/1", + } + summary = contract_to_summary(c) + assert "Test Tender" in summary + assert "TC-001" in summary + assert "Comune di Roma" in summary + assert "150,000" in summary + + def test_missing_fields(self) -> None: + c = {"title": "Minimal"} + summary = contract_to_summary(c) + assert "Minimal" in summary + assert "N/A" in summary + + +class TestLoadYamlConfig: + """Tests for load_yaml_config().""" + + def test_load_existing_yaml(self) -> None: + data = load_yaml_config("portals.yaml") + assert "portals" in data + assert isinstance(data["portals"], list) + + def test_missing_yaml_raises(self) -> None: + with pytest.raises(FileNotFoundError, match="Config file not found"): + load_yaml_config("nonexistent_file.yaml") + + def test_caching(self) -> None: + # Two calls should return the same object + a = load_yaml_config("portals.yaml") + b = load_yaml_config("portals.yaml") + assert a is b + + def test_cache_bypass(self) -> None: + a = load_yaml_config("portals.yaml", use_cache=True) + b = load_yaml_config("portals.yaml", use_cache=False) + # Same content, different objects + assert a == b From d4a46333af4299e589c6c47d19b7c76d7819af36 Mon Sep 17 00:00:00 2001 From: Alessandro Sidero <75628365+Alessandro624@users.noreply.github.com> Date: Thu, 21 May 2026 23:09:48 +0200 Subject: [PATCH 10/10] test: update python version in tests.yml --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 621781a..4667da5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4