Spaces:
Running
Running
feat(ci): add test suite and run tests in CI before deploy (#7)
Browse files36 tests covering ConsultationState, Config, and app construction.
CI now runs pytest on ubuntu-latest before deploy-prod and sync-hf.
Co-authored-by: overthelex <mcvovkes@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- .github/workflows/deploy.yml +19 -0
- tests/conftest.py +5 -0
- tests/test_app.py +35 -0
- tests/test_config.py +66 -0
- tests/test_state.py +154 -0
.github/workflows/deploy.yml
CHANGED
|
@@ -10,8 +10,26 @@ concurrency:
|
|
| 10 |
cancel-in-progress: false
|
| 11 |
|
| 12 |
jobs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
deploy-prod:
|
| 14 |
name: Deploy to prod
|
|
|
|
| 15 |
runs-on: [self-hosted, local]
|
| 16 |
timeout-minutes: 10
|
| 17 |
steps:
|
|
@@ -45,6 +63,7 @@ jobs:
|
|
| 45 |
|
| 46 |
sync-hf:
|
| 47 |
name: Sync to HuggingFace Space
|
|
|
|
| 48 |
runs-on: ubuntu-latest
|
| 49 |
timeout-minutes: 5
|
| 50 |
steps:
|
|
|
|
| 10 |
cancel-in-progress: false
|
| 11 |
|
| 12 |
jobs:
|
| 13 |
+
test:
|
| 14 |
+
name: Test
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
timeout-minutes: 5
|
| 17 |
+
steps:
|
| 18 |
+
- uses: actions/checkout@v4
|
| 19 |
+
|
| 20 |
+
- uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: "3.12"
|
| 23 |
+
|
| 24 |
+
- name: Install dependencies
|
| 25 |
+
run: pip install -r requirements.txt pytest
|
| 26 |
+
|
| 27 |
+
- name: Run tests
|
| 28 |
+
run: python -m pytest tests/ -v
|
| 29 |
+
|
| 30 |
deploy-prod:
|
| 31 |
name: Deploy to prod
|
| 32 |
+
needs: test
|
| 33 |
runs-on: [self-hosted, local]
|
| 34 |
timeout-minutes: 10
|
| 35 |
steps:
|
|
|
|
| 63 |
|
| 64 |
sync-hf:
|
| 65 |
name: Sync to HuggingFace Space
|
| 66 |
+
needs: test
|
| 67 |
runs-on: ubuntu-latest
|
| 68 |
timeout-minutes: 5
|
| 69 |
steps:
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Gradio app construction."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
gradio = pytest.importorskip("gradio")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_architecture_html_exists():
|
| 11 |
+
path = Path(__file__).parent.parent / "architecture.html"
|
| 12 |
+
assert path.exists(), "architecture.html not found"
|
| 13 |
+
content = path.read_text()
|
| 14 |
+
assert "lmaf-arch" in content
|
| 15 |
+
assert "<script>" in content
|
| 16 |
+
assert "d3" in content
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_architecture_html_is_valid_document():
|
| 20 |
+
path = Path(__file__).parent.parent / "architecture.html"
|
| 21 |
+
content = path.read_text()
|
| 22 |
+
assert content.strip().startswith("<!DOCTYPE html>")
|
| 23 |
+
assert "</html>" in content
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_app_builds():
|
| 27 |
+
import app
|
| 28 |
+
demo = app.build_app()
|
| 29 |
+
assert demo is not None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_app_has_iframe_srcdoc():
|
| 33 |
+
import app
|
| 34 |
+
assert "iframe" in app.ARCHITECTURE_HTML
|
| 35 |
+
assert "srcdoc" in app.ARCHITECTURE_HTML
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Config."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from lmaf.core.config import Config
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestConfig:
|
| 14 |
+
def test_defaults(self):
|
| 15 |
+
config = Config()
|
| 16 |
+
assert config.max_iterations == 15
|
| 17 |
+
assert config.critic_every_n == 3
|
| 18 |
+
assert config.max_consecutive_failures == 3
|
| 19 |
+
assert "eu.anthropic" in config.default_model
|
| 20 |
+
|
| 21 |
+
def test_model_for_default(self):
|
| 22 |
+
config = Config()
|
| 23 |
+
assert config.model_for("surveyor") == config.default_model
|
| 24 |
+
|
| 25 |
+
def test_model_for_override(self):
|
| 26 |
+
config = Config(model_overrides={"surveyor": "custom-model"})
|
| 27 |
+
assert config.model_for("surveyor") == "custom-model"
|
| 28 |
+
assert config.model_for("planner") == config.default_model
|
| 29 |
+
|
| 30 |
+
def test_from_yaml(self):
|
| 31 |
+
with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f:
|
| 32 |
+
f.write("max_iterations: 5\ncritic_every_n: 2\n")
|
| 33 |
+
path = Path(f.name)
|
| 34 |
+
|
| 35 |
+
config = Config.from_yaml(path)
|
| 36 |
+
assert config.max_iterations == 5
|
| 37 |
+
assert config.critic_every_n == 2
|
| 38 |
+
path.unlink()
|
| 39 |
+
|
| 40 |
+
def test_from_yaml_ignores_unknown_fields(self):
|
| 41 |
+
with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f:
|
| 42 |
+
f.write("max_iterations: 10\nunknown_field: true\n")
|
| 43 |
+
path = Path(f.name)
|
| 44 |
+
|
| 45 |
+
config = Config.from_yaml(path)
|
| 46 |
+
assert config.max_iterations == 10
|
| 47 |
+
path.unlink()
|
| 48 |
+
|
| 49 |
+
def test_from_env(self):
|
| 50 |
+
env = {
|
| 51 |
+
"AWS_REGION": "us-east-1",
|
| 52 |
+
"AWS_ACCESS_KEY_ID": "test-key",
|
| 53 |
+
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
| 54 |
+
"SECONDLAYER_API_KEY": "sl-key",
|
| 55 |
+
}
|
| 56 |
+
with patch.dict(os.environ, env, clear=False):
|
| 57 |
+
config = Config.from_env()
|
| 58 |
+
assert config.aws_region == "us-east-1"
|
| 59 |
+
assert config.aws_access_key_id == "test-key"
|
| 60 |
+
assert config.secondlayer_api_key == "sl-key"
|
| 61 |
+
|
| 62 |
+
def test_from_env_defaults(self):
|
| 63 |
+
with patch.dict(os.environ, {}, clear=True):
|
| 64 |
+
config = Config.from_env()
|
| 65 |
+
assert config.aws_region == ""
|
| 66 |
+
assert config.secondlayer_api_url == "https://legal.org.ua/api"
|
tests/test_state.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ConsultationState and its data structures."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from lmaf.state.research_state import (
|
| 10 |
+
ConsultationState,
|
| 11 |
+
Critique,
|
| 12 |
+
CritiqueStatus,
|
| 13 |
+
HypothesisStatus,
|
| 14 |
+
LegalEvidence,
|
| 15 |
+
LegalHypothesis,
|
| 16 |
+
LegalStrategy,
|
| 17 |
+
RQStatus,
|
| 18 |
+
ResearchQuestion,
|
| 19 |
+
Severity,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TestConsultationState:
|
| 24 |
+
def test_empty_state(self):
|
| 25 |
+
state = ConsultationState()
|
| 26 |
+
assert state.client_question == ""
|
| 27 |
+
assert state.hypotheses == []
|
| 28 |
+
assert state.evidence == []
|
| 29 |
+
assert state.questions == []
|
| 30 |
+
assert state.critiques == []
|
| 31 |
+
assert state.iteration == 0
|
| 32 |
+
|
| 33 |
+
def test_add_hypothesis(self):
|
| 34 |
+
state = ConsultationState()
|
| 35 |
+
h = state.add_hypothesis("Позовна давність становить 3 роки", iteration=1)
|
| 36 |
+
assert h.id == "H-001"
|
| 37 |
+
assert h.status == HypothesisStatus.WORKING
|
| 38 |
+
assert len(state.hypotheses) == 1
|
| 39 |
+
|
| 40 |
+
def test_add_multiple_hypotheses(self):
|
| 41 |
+
state = ConsultationState()
|
| 42 |
+
state.add_hypothesis("First")
|
| 43 |
+
state.add_hypothesis("Second")
|
| 44 |
+
state.add_hypothesis("Third")
|
| 45 |
+
assert len(state.hypotheses) == 3
|
| 46 |
+
assert state.hypotheses[2].id == "H-003"
|
| 47 |
+
|
| 48 |
+
def test_add_evidence(self):
|
| 49 |
+
state = ConsultationState()
|
| 50 |
+
ev = state.add_evidence(
|
| 51 |
+
type="case_law",
|
| 52 |
+
source="edrsr",
|
| 53 |
+
summary="Рішення ВС щодо строків давності",
|
| 54 |
+
citation="Справа №757/12345/22",
|
| 55 |
+
)
|
| 56 |
+
assert ev.id == "EV-001"
|
| 57 |
+
assert ev.type == "case_law"
|
| 58 |
+
assert not ev.refuted
|
| 59 |
+
|
| 60 |
+
def test_add_question(self):
|
| 61 |
+
state = ConsultationState()
|
| 62 |
+
rq = state.add_question("Чи застосовується ст. 625 ЦК?", iteration=2)
|
| 63 |
+
assert rq.id == "RQ-001"
|
| 64 |
+
assert rq.status == RQStatus.OPEN
|
| 65 |
+
|
| 66 |
+
def test_add_critique(self):
|
| 67 |
+
state = ConsultationState()
|
| 68 |
+
c = state.add_critique(
|
| 69 |
+
type="strategy",
|
| 70 |
+
severity=Severity.HIGH,
|
| 71 |
+
summary="Не враховано зустрічний позов",
|
| 72 |
+
)
|
| 73 |
+
assert c.id == "CR-001"
|
| 74 |
+
assert c.status == CritiqueStatus.ACTIVE
|
| 75 |
+
|
| 76 |
+
def test_open_questions(self):
|
| 77 |
+
state = ConsultationState()
|
| 78 |
+
q1 = state.add_question("Open question")
|
| 79 |
+
q2 = state.add_question("Resolved question")
|
| 80 |
+
q2.status = RQStatus.RESOLVED
|
| 81 |
+
assert len(state.open_questions()) == 1
|
| 82 |
+
assert state.open_questions()[0].id == q1.id
|
| 83 |
+
|
| 84 |
+
def test_active_critiques(self):
|
| 85 |
+
state = ConsultationState()
|
| 86 |
+
c1 = state.add_critique(type="strategy", summary="Active")
|
| 87 |
+
c2 = state.add_critique(type="reasoning", summary="Resolved")
|
| 88 |
+
c2.status = CritiqueStatus.RESOLVED
|
| 89 |
+
assert len(state.active_critiques()) == 1
|
| 90 |
+
assert state.active_critiques()[0].id == c1.id
|
| 91 |
+
|
| 92 |
+
def test_working_hypotheses(self):
|
| 93 |
+
state = ConsultationState()
|
| 94 |
+
h1 = state.add_hypothesis("Working")
|
| 95 |
+
h2 = state.add_hypothesis("Established")
|
| 96 |
+
h2.status = HypothesisStatus.ESTABLISHED
|
| 97 |
+
assert len(state.working_hypotheses()) == 1
|
| 98 |
+
assert len(state.established_hypotheses()) == 1
|
| 99 |
+
|
| 100 |
+
def test_serialization_roundtrip(self):
|
| 101 |
+
state = ConsultationState()
|
| 102 |
+
state.client_question = "Тестове питання"
|
| 103 |
+
state.jurisdiction = "civil"
|
| 104 |
+
state.survey_summary = "Огляд правового ландшафту"
|
| 105 |
+
state.strategy = LegalStrategy(
|
| 106 |
+
approach="Аналіз строків давності",
|
| 107 |
+
legal_domains=["цивільне право"],
|
| 108 |
+
key_questions=["Який строк?"],
|
| 109 |
+
)
|
| 110 |
+
state.add_hypothesis("Гіпотеза 1", iteration=1)
|
| 111 |
+
state.add_evidence(type="legislation", citation="ст. 257 ЦК")
|
| 112 |
+
state.add_question("Питання 1")
|
| 113 |
+
state.add_critique(type="completeness", summary="Прогалина")
|
| 114 |
+
|
| 115 |
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
| 116 |
+
path = Path(f.name)
|
| 117 |
+
|
| 118 |
+
state.save(path)
|
| 119 |
+
loaded = ConsultationState.load(path)
|
| 120 |
+
|
| 121 |
+
assert loaded.client_question == "Тестове питання"
|
| 122 |
+
assert loaded.jurisdiction == "civil"
|
| 123 |
+
assert loaded.strategy.approach == "Аналіз строків давності"
|
| 124 |
+
assert len(loaded.hypotheses) == 1
|
| 125 |
+
assert len(loaded.evidence) == 1
|
| 126 |
+
assert len(loaded.questions) == 1
|
| 127 |
+
assert len(loaded.critiques) == 1
|
| 128 |
+
path.unlink()
|
| 129 |
+
|
| 130 |
+
def test_to_dict(self):
|
| 131 |
+
state = ConsultationState()
|
| 132 |
+
state.client_question = "Test"
|
| 133 |
+
d = state.to_dict()
|
| 134 |
+
assert isinstance(d, dict)
|
| 135 |
+
assert d["client_question"] == "Test"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class TestLegalEvidence:
|
| 139 |
+
def test_short(self):
|
| 140 |
+
ev = LegalEvidence(id="EV-001", citation="ст. 625 ЦК", summary="Пеня за прострочення")
|
| 141 |
+
assert "EV-001" in ev.short()
|
| 142 |
+
assert "ст. 625 ЦК" in ev.short()
|
| 143 |
+
|
| 144 |
+
def test_defaults(self):
|
| 145 |
+
ev = LegalEvidence()
|
| 146 |
+
assert ev.refuted is False
|
| 147 |
+
assert ev.iteration is None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestLegalHypothesis:
|
| 151 |
+
def test_short(self):
|
| 152 |
+
h = LegalHypothesis(id="H-001", statement="Позов обгрунтований", status=HypothesisStatus.WORKING)
|
| 153 |
+
assert "H-001" in h.short()
|
| 154 |
+
assert "working" in h.short()
|