Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Integration Tests

on:
pull_request:
branches:
- main
push:
branches:
- main

jobs:
integration-tests:
name: Run Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-asyncio

- name: Generate config.yaml from secrets
env:
COSMOS_URI: ${{ secrets.COSMOS_URI }}
COSMOS_KEY: ${{ secrets.COSMOS_KEY }}
COSMOS_DATABASE_NAME: ${{ secrets.COSMOS_DATABASE_NAME }}
COSMOS_SOURCE1_CONTAINER: ${{ secrets.COSMOS_SOURCE1_CONTAINER }}
COSMOS_SOURCE2_CONTAINER: ${{ secrets.COSMOS_SOURCE2_CONTAINER }}
LLM_ENDPOINT: ${{ secrets.LLM_ENDPOINT }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
EMBED_ENDPOINT: ${{ secrets.EMBED_ENDPOINT }}
EMBED_API_KEY: ${{ secrets.EMBED_API_KEY }}
EMBED_MODEL: ${{ secrets.EMBED_MODEL }}
EMBED_DIMENSIONS: ${{ secrets.EMBED_DIMENSIONS }}
run: python scripts/generate_config.py

- name: Run integration tests
run: pytest tests/ -v --tb=short
89 changes: 89 additions & 0 deletions scripts/generate_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python
"""Generate config.yaml from config.yaml.example by substituting environment variables.

Environment variables consumed
-------------------------------
COSMOS_URI – Cosmos DB account URI
COSMOS_KEY – Cosmos DB primary key
COSMOS_DATABASE_NAME – database name (default: divdet)
COSMOS_SOURCE1_CONTAINER – container name for source_1 (default: container_1)
COSMOS_SOURCE2_CONTAINER – container name for source_2 (default: container_2)
LLM_ENDPOINT – Azure OpenAI LLM endpoint URL
LLM_API_KEY – Azure OpenAI LLM API key
LLM_MODEL – LLM deployment/model name
EMBED_ENDPOINT – Azure OpenAI embedding endpoint URL
EMBED_API_KEY – Azure OpenAI embedding API key
EMBED_MODEL – Embedding deployment/model name
EMBED_DIMENSIONS – Embedding output dimensions (default: 1024)
"""
import os
import sys
from pathlib import Path

import yaml

REPO_ROOT = Path(__file__).parent.parent
EXAMPLE_PATH = REPO_ROOT / "config.yaml.example"
OUTPUT_PATH = REPO_ROOT / "config.yaml"


def _require(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
print(f"ERROR: required environment variable '{name}' is not set or empty.", file=sys.stderr)
sys.exit(1)
return value


def _optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip() or default


def main():
if not EXAMPLE_PATH.exists():
print(f"ERROR: {EXAMPLE_PATH} not found.", file=sys.stderr)
sys.exit(1)

with open(EXAMPLE_PATH) as fh:
cfg = yaml.safe_load(fh)

# --- Cosmos DB -----------------------------------------------------------
cfg["cosmos"]["uri"] = _require("COSMOS_URI")
cfg["cosmos"]["key"] = _require("COSMOS_KEY")
cfg["cosmos"]["database_name"] = _optional("COSMOS_DATABASE_NAME", "divdet")

# Patch container names inside sources list
container_overrides = {
"source_1": _optional("COSMOS_SOURCE1_CONTAINER", "container_1"),
"source_2": _optional("COSMOS_SOURCE2_CONTAINER", "container_2"),
}
for source in cfg.get("cosmos", {}).get("sources", []):
source_id = source.get("id", "")
if source_id in container_overrides:
source["container_name"] = container_overrides[source_id]

# --- LLM -----------------------------------------------------------------
cfg["llm"]["llm_endpoint"] = _require("LLM_ENDPOINT")
cfg["llm"]["llm_api_key"] = _require("LLM_API_KEY")
cfg["llm"]["llm_model"] = _optional("LLM_MODEL", cfg["llm"].get("llm_model", "model-router"))

# --- Embedding -----------------------------------------------------------
if "embedding" not in cfg:
cfg["embedding"] = {}
cfg["embedding"]["embed_endpoint"] = _require("EMBED_ENDPOINT")
cfg["embedding"]["embed_api_key"] = _require("EMBED_API_KEY")
cfg["embedding"]["embed_model"] = _require("EMBED_MODEL")
cfg["embedding"]["embed_dimensions"] = int(_optional("EMBED_DIMENSIONS", "1024"))

# Disable local LLM fallback in CI to avoid noise
cfg.setdefault("local_llm", {})["use_local_fallback_for_subtasks"] = False

# Write output
with open(OUTPUT_PATH, "w") as fh:
yaml.dump(cfg, fh, allow_unicode=True, sort_keys=False)

print(f"config.yaml written to {OUTPUT_PATH}")


if __name__ == "__main__":
main()
67 changes: 67 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Shared pytest fixtures for integration tests."""
import sys
import asyncio
from pathlib import Path

import pytest
import yaml

# Ensure the repo root is on the path so rag_divdet can be imported
REPO_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(REPO_ROOT))


# ---------------------------------------------------------------------------
# Event-loop fixture (module-scoped so all fixtures share one loop)
# ---------------------------------------------------------------------------

@pytest.fixture(scope="module")
def event_loop():
"""Create a module-scoped event loop shared by all fixtures and tests."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()


# ---------------------------------------------------------------------------
# Config fixture: load config.yaml and expose as a dict
# ---------------------------------------------------------------------------

@pytest.fixture(scope="module")
def config():
config_path = REPO_ROOT / "config.yaml"
if not config_path.exists():
pytest.skip("config.yaml not found – skipping integration tests")
with open(config_path) as fh:
return yaml.safe_load(fh)


# ---------------------------------------------------------------------------
# LLMClient fixture
# ---------------------------------------------------------------------------

@pytest.fixture(scope="module")
def llm_client(config, event_loop):
"""Return an initialised LLMClient (module-scoped to avoid repeated setup)."""
import rag_divdet as rd
client = rd.LLMClient()
yield client
event_loop.run_until_complete(client.close())


# ---------------------------------------------------------------------------
# CombinedRetriever fixture
# ---------------------------------------------------------------------------

@pytest.fixture(scope="module")
def retriever(config, event_loop):
"""Return an initialised CombinedRetriever (module-scoped)."""
import rag_divdet as rd
sources = rd._build_retrieval_sources(config)
if not sources:
pytest.skip("No retrieval sources configured – skipping retriever tests")
ret = rd.CombinedRetriever(retrieval_sources=sources)
event_loop.run_until_complete(ret.initialize())
yield ret
event_loop.run_until_complete(ret.close())
Loading