diff --git a/.env.example b/.env.example index 8620911..84463a4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # ============================================================================== # FOWOCO AI Agent Server — Environment Variables (.env.example) # ============================================================================== +# CI처럼 로컬 .env를 읽지 않아야 하는 실행은 FOWOCO_ENV_FILE=/dev/null 사용 # ------------------------------------------------------------------------------ # 1. Language Assistant — LLM 연동 설정 (OpenAI 호환) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db5c2d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: ci + +on: + pull_request: + branches: [develop, main] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ai-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + PYTHONPATH: . + FOWOCO_ENV_FILE: /dev/null + FOWOCO_CLOVA_OCR_ENABLED: "false" + FOWOCO_INTENT_MODEL_ENABLED: "false" + FOWOCO_INTENT_ENABLE_AX: "false" + FOWOCO_INTENT_WARMUP_ON_START: "false" + FOWOCO_INTENT_WARMUP_REQUIRED: "false" + +jobs: + core: + name: Core pytest · App Ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v6 + with: + version: "0.12.5" + enable-cache: true + + - name: Install core test dependencies + run: uv sync --frozen --extra dev + + - name: Lint app and tests + run: uv run ruff check app tests + + - name: Run deterministic core tests + run: >- + uv run pytest + -m "not qdrant_integration and not language_models and not rhwp_integration and not windows_ocr" + + qdrant-contract: + name: Qdrant contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v6 + with: + version: "0.12.5" + enable-cache: true + + - name: Install retrieval dependencies + run: uv sync --frozen --extra dev --extra language-retrieval + + - name: Run Qdrant contract tests + run: >- + uv run pytest + tests/integration/language/test_qdrant_retrieval.py + tests/integration/language/test_runtime_composition.py + -m qdrant_integration + + windows-ocr-contract: + name: Windows OCR script contract + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v6 + with: + version: "0.12.5" + enable-cache: true + + - name: Install test dependencies + run: uv sync --frozen --extra dev + + - name: Run PowerShell OCR contract tests + run: uv run pytest tests/ocr/test_smoke_script.py -m windows_ocr diff --git a/app/agents/intent/guardrail.py b/app/agents/intent/guardrail.py index 1c6efef..53db6d2 100644 --- a/app/agents/intent/guardrail.py +++ b/app/agents/intent/guardrail.py @@ -43,7 +43,10 @@ def should_route_to_ax( if activated_count >= self.max_trained_labels: return RoutingResult( should_route=True, - reason=f"활성 label {activated_count}개 (학습 최댓값 {self.max_trained_labels}개 이상)", + reason=( + f"활성 label {activated_count}개 " + f"(학습 최댓값 {self.max_trained_labels}개 이상)" + ), category="OOD_Label_Count", ) if any(kw in clean_input for kw in self.status_kw): diff --git a/app/agents/workflow_graph/status.py b/app/agents/workflow_graph/status.py index d5ac1d2..cbac363 100644 --- a/app/agents/workflow_graph/status.py +++ b/app/agents/workflow_graph/status.py @@ -51,7 +51,12 @@ def to_public_status(status: str | None) -> str: ALLOWED_TRANSITIONS: dict[TaskStatus, frozenset[TaskStatus]] = { TaskStatus.DRAFT: frozenset( - {TaskStatus.NEEDS_INFO, TaskStatus.READY_FOR_REVIEW, TaskStatus.WAITING_WORKER, TaskStatus.CANCELLED} + { + TaskStatus.NEEDS_INFO, + TaskStatus.READY_FOR_REVIEW, + TaskStatus.WAITING_WORKER, + TaskStatus.CANCELLED, + } ), TaskStatus.NEEDS_INFO: frozenset( { diff --git a/app/core/config.py b/app/core/config.py index bdc6ecd..7adc99b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,3 +1,4 @@ +import os import tempfile from functools import lru_cache from pathlib import Path @@ -10,7 +11,7 @@ # 환경변수·.env에서 앱 설정을 읽어 오는 모델 class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + env_file=os.getenv("FOWOCO_ENV_FILE", ".env"), env_file_encoding="utf-8", env_prefix="FOWOCO_", extra="ignore", diff --git a/pyproject.toml b/pyproject.toml index bd41a56..97e3894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ markers = [ "qdrant_integration: mark test as requiring Qdrant integration", "language_models: mark test as requiring language model cache", "rhwp_integration: mark test as requiring the rhwp executable", + "windows_ocr: mark test as requiring PowerShell on Windows", ] diff --git a/tests/integration/language/test_qdrant_retrieval.py b/tests/integration/language/test_qdrant_retrieval.py index ffac1bb..912506e 100644 --- a/tests/integration/language/test_qdrant_retrieval.py +++ b/tests/integration/language/test_qdrant_retrieval.py @@ -6,6 +6,8 @@ from app.agents.language.retrieval.models import ExpectedIndexContract, HybridVector from app.agents.language.retrieval.qdrant_store import QdrantStore +pytestmark = pytest.mark.qdrant_integration + @pytest.fixture def expected_contract() -> ExpectedIndexContract: diff --git a/tests/integration/language/test_runtime_composition.py b/tests/integration/language/test_runtime_composition.py index d229d8c..a12a89f 100644 --- a/tests/integration/language/test_runtime_composition.py +++ b/tests/integration/language/test_runtime_composition.py @@ -97,6 +97,7 @@ def test_factory_selects_native_adapter_for_ollama_provider() -> None: assert isinstance(generator, OllamaGenerationPort) +@pytest.mark.qdrant_integration def test_factory_selects_hybrid_retriever_when_qdrant_is_configured() -> None: from app.agents.language.composition import _build_production_ports @@ -112,6 +113,7 @@ def test_factory_selects_hybrid_retriever_when_qdrant_is_configured() -> None: assert isinstance(retriever, HybridEpsRetriever) +@pytest.mark.qdrant_integration def test_factory_wires_fixed_revision_reranker_when_qdrant_is_configured( tmp_path: Path, ) -> None: diff --git a/tests/ocr/test_smoke_script.py b/tests/ocr/test_smoke_script.py index f370e03..54d7797 100644 --- a/tests/ocr/test_smoke_script.py +++ b/tests/ocr/test_smoke_script.py @@ -6,6 +6,16 @@ from pathlib import Path from uuid import UUID +import pytest + +pytestmark = [ + pytest.mark.windows_ocr, + pytest.mark.skipif( + shutil.which("powershell") is None and shutil.which("pwsh") is None, + reason="PowerShell is unavailable on this operating system", + ), +] + def _powershell() -> str: executable = shutil.which("powershell") or shutil.which("pwsh")