diff --git a/credit/Dockerfile.serve.demo b/credit/Dockerfile.serve.demo new file mode 100644 index 0000000..669170b --- /dev/null +++ b/credit/Dockerfile.serve.demo @@ -0,0 +1,31 @@ +# Credit CSS FastAPI — demo bake image (joblib inside the image, no volume). +# Build from repo root AFTER preparing the artifact: +# ./platform/scripts/prepare-serve-demo-artifact.sh +# docker build -f credit/Dockerfile.serve.demo -t credit-css-serve-demo . +# Run (no -v mount): +# docker run --rm -p 8000:8000 credit-css-serve-demo +# +# Local volume-mount serving stays on credit/Dockerfile.serve. + +FROM python:3.11-slim-bookworm + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml poetry.lock README.md ./ +COPY src ./src +COPY credit ./credit + +RUN pip install --no-cache-dir poetry \ + && poetry config virtualenvs.create false \ + && poetry install --only main --no-ansi --no-interaction + +# Baked hermetic fit output (gitignored under credit/demo_artifact/). +COPY credit/demo_artifact/pipeline.joblib /artifact/pipeline.joblib +ENV CREDIT_PIPELINE_ARTIFACT=/artifact/pipeline.joblib +EXPOSE 8000 + +CMD ["uvicorn", "credit.api:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/credit/docs/features/aws-fargate-serve-demo.md b/credit/docs/features/aws-fargate-serve-demo.md index c0559e4..f5b6da4 100644 --- a/credit/docs/features/aws-fargate-serve-demo.md +++ b/credit/docs/features/aws-fargate-serve-demo.md @@ -1,11 +1,11 @@ # Feature: Ephemeral CSS FastAPI serve on AWS (Fargate) -**Status:** planned — docs lock in progress (#172); bake-image (#173) then AWS HITL (#174) +**Status:** in progress — docs done (#172); **bake-image (#173) in this cut**; AWS HITL (#174) next **GitHub:** parent [#171](https://github.com/JLaborda/SmartWealthAI/issues/171) · [#172](https://github.com/JLaborda/SmartWealthAI/issues/172) docs · [#173](https://github.com/JLaborda/SmartWealthAI/issues/173) bake-image · [#174](https://github.com/JLaborda/SmartWealthAI/issues/174) AWS up/down **Domain:** credit / CSS (demo surface) + shared `platform/` (IaC) **Glossary:** [`../../CONTEXT.md`](../../CONTEXT.md) (domain only — no AWS terms) · Map: [`../../../CONTEXT-MAP.md`](../../../CONTEXT-MAP.md) -**Related:** chapter 5 [`css-chapter5-mart-and-scoring.md`](css-chapter5-mart-and-scoring.md) · PSI [`psi-data-drift.md`](psi-data-drift.md) · Friday MUST [`../guides/friday-demo.md`](../guides/friday-demo.md) · [ADR-0004](../../../docs/adr/0004-platform-ephemeral-fargate-serve.md) -**Code (target):** `platform/terraform/credit-css-serve-demo/`, `platform/scripts/demo-up.sh`, `platform/scripts/demo-down.sh`, `credit/Dockerfile.serve.demo` +**Related:** chapter 5 [`css-chapter5-mart-and-scoring.md`](css-chapter5-mart-and-scoring.md) · PSI [`psi-data-drift.md`](psi-data-drift.md) · Friday MUST [`../guides/friday-demo.md`](../guides/friday-demo.md) · [ADR-0004](../../../docs/adr/0004-platform-ephemeral-fargate-serve.md) · bake guide [`../guides/serve-fastapi.md`](../guides/serve-fastapi.md) +**Code:** `credit/demo_serve_artifact.py`, `credit/Dockerfile.serve.demo`, `platform/scripts/prepare-serve-demo-artifact.sh` · Tests: `tests/credit/test_serve_demo_bake.py` · Later: `platform/terraform/credit-css-serve-demo/`, `demo-up.sh` / `demo-down.sh` ## Problem Statement @@ -66,11 +66,12 @@ No S3 data lake, no auth, no GitHub CD in this cut. ## Acceptance criteria -- [ ] Feature spec + ADR-0004 + friday-demo stretch link exist in English -- [ ] With a valid `AWS_PROFILE`, `demo-up` yields reachable `/health` and successful `/score` + `/drivers` using existing serving fixtures -- [ ] `demo-down` destroys the stack including ECR images -- [ ] Local volume-mount serve Docker path remains documented and unchanged in behaviour -- [ ] No AWS vocabulary added to `credit/CONTEXT.md` +- [x] Feature spec + ADR-0004 + friday-demo stretch link exist in English (#172) +- [x] Bake-image local path: `prepare-serve-demo-artifact` + `Dockerfile.serve.demo` + hermetic pytest + documented docker smoke without volume (#173) +- [ ] With a valid `AWS_PROFILE`, `demo-up` yields reachable `/health` and successful `/score` + `/drivers` using existing serving fixtures (#174) +- [ ] `demo-down` destroys the stack including ECR images (#174) +- [x] Local volume-mount serve Docker path remains documented and unchanged in behaviour +- [x] No AWS vocabulary added to `credit/CONTEXT.md` ## Out of Scope diff --git a/credit/docs/guides/serve-fastapi.md b/credit/docs/guides/serve-fastapi.md index 064dac7..0fb4b66 100644 --- a/credit/docs/guides/serve-fastapi.md +++ b/credit/docs/guides/serve-fastapi.md @@ -58,7 +58,7 @@ curl -s localhost:8000/drivers \ -d @tests/credit/fixtures/serving/drivers_request.json ``` -## Docker +## Docker (volume mount) ```bash docker build -f credit/Dockerfile.serve -t credit-css-serve . @@ -68,6 +68,18 @@ docker run --rm -p 8000:8000 \ credit-css-serve ``` +## Docker (bake-image demo — no volume) + +Hermetic fit into gitignored `credit/demo_artifact/`, then an image that embeds `pipeline.joblib` (for local smoke and later Fargate). Spec: [`../features/aws-fargate-serve-demo.md`](../features/aws-fargate-serve-demo.md) (#173). + +```bash +./platform/scripts/prepare-serve-demo-artifact.sh +docker build -f credit/Dockerfile.serve.demo -t credit-css-serve-demo . +docker run --rm -p 8000:8000 credit-css-serve-demo +``` + +Same curls as above (no `-v`). Volume-mount `Dockerfile.serve` remains the default local path. + ## Out of this guide -PSI CLI, Terraform/AWS, SHAP on drivers — later Friday-demo slices. +PSI CLI, Terraform/AWS up-down (#174), SHAP on drivers — later slices. diff --git a/credit/src/credit/demo_serve_artifact.py b/credit/src/credit/demo_serve_artifact.py new file mode 100644 index 0000000..3601ff2 --- /dev/null +++ b/credit/src/credit/demo_serve_artifact.py @@ -0,0 +1,54 @@ +"""Prepare a hermetic ``pipeline.joblib`` for the bake-image serve demo (#173).""" + +from __future__ import annotations + +from pathlib import Path + +from credit.application_mart import build_application_mart +from credit.scoring_pipeline import ARTIFACT_NAME, fit_scoring_pipeline + +# Default relative to repo root when callers pass only output_dir via the script. +DEFAULT_SCORING_FIXTURE = ( + Path(__file__).resolve().parents[3] + / "tests" + / "credit" + / "fixtures" + / "scoring_mart" + / "applications.csv" +) + + +def prepare_serve_demo_artifact( + output_dir: Path, + *, + source: Path | None = None, +) -> Path: + """Fit a CSS pipeline into ``output_dir`` and return the joblib path. + + Uses the hermetic scoring fixture unless ``source`` is provided. Mart + intermediates go under ``output_dir / "_mart"``. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + source_path = Path(source) if source is not None else DEFAULT_SCORING_FIXTURE + + mart = build_application_mart( + source_path, + output_dir / "_mart", + application_id_column="customer_ID", + target_column="target", + bad_value=1, + good_value=0, + ) + fit_scoring_pipeline( + mart.mart_path, + output_dir, + application_id_column="customer_ID", + target_column="target", + random_state=42, + skip_mlflow=True, + ) + artifact = output_dir / ARTIFACT_NAME + if not artifact.is_file(): + raise FileNotFoundError(f"Expected artifact missing: {artifact}") + return artifact diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..347ae1e --- /dev/null +++ b/platform/README.md @@ -0,0 +1,11 @@ +# Platform + +Shared AWS / Terraform and operator scripts. Domain language stays in `credit/` and investing docs — see [`CONTEXT-MAP.md`](../CONTEXT-MAP.md) and [ADR-0004](../docs/adr/0004-platform-ephemeral-fargate-serve.md). + +## Scripts (credit CSS serve demo) + +| Script | Purpose | +| --- | --- | +| `scripts/prepare-serve-demo-artifact.sh` | Hermetic fit → `credit/demo_artifact/pipeline.joblib` for `credit/Dockerfile.serve.demo` (#173) | + +AWS `demo-up` / `demo-down` land with [#174](https://github.com/JLaborda/SmartWealthAI/issues/174). diff --git a/platform/scripts/prepare-serve-demo-artifact.sh b/platform/scripts/prepare-serve-demo-artifact.sh new file mode 100755 index 0000000..1080934 --- /dev/null +++ b/platform/scripts/prepare-serve-demo-artifact.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Hermetic fit → credit/demo_artifact/pipeline.joblib for Dockerfile.serve.demo (#173). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +OUT="${CREDIT_DEMO_ARTIFACT_DIR:-$ROOT/credit/demo_artifact}" + +poetry run python -c " +from pathlib import Path +from credit.demo_serve_artifact import prepare_serve_demo_artifact +path = prepare_serve_demo_artifact(Path(r'''$OUT''')) +print(path) +" diff --git a/tests/credit/test_serve_demo_bake.py b/tests/credit/test_serve_demo_bake.py new file mode 100644 index 0000000..cba68d7 --- /dev/null +++ b/tests/credit/test_serve_demo_bake.py @@ -0,0 +1,75 @@ +"""Seam A: prepare demo artifact → FastAPI score/drivers (bake path, no Docker).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from credit.scoring_pipeline import ARTIFACT_NAME + +EXAMPLE_PAYLOAD = Path(__file__).parent / "fixtures" / "serving" / "score_request.json" +DRIVERS_PAYLOAD = Path(__file__).parent / "fixtures" / "serving" / "drivers_request.json" +SCORING_FIXTURE = Path(__file__).parent / "fixtures" / "scoring_mart" / "applications.csv" + + +def test_prepare_serve_demo_artifact_serves_score_and_drivers(tmp_path: Path) -> None: + """Hermetic prepare → joblib under output_dir → /score and /drivers succeed.""" + from credit.api import create_app + from credit.demo_serve_artifact import prepare_serve_demo_artifact + + artifact = prepare_serve_demo_artifact( + tmp_path / "demo_artifact", + source=SCORING_FIXTURE, + ) + assert artifact == tmp_path / "demo_artifact" / ARTIFACT_NAME + assert artifact.is_file() + + client = TestClient(create_app(artifact_path=artifact)) + score_body = json.loads(EXAMPLE_PAYLOAD.read_text(encoding="utf-8")) + drivers_body = json.loads(DRIVERS_PAYLOAD.read_text(encoding="utf-8")) + + score = client.post("/score", json=score_body) + assert score.status_code == 200, score.text + score_payload = score.json() + assert score_payload["application_id"] == score_body["application_id"] + assert 0.0 <= score_payload["pd"] <= 1.0 + assert 250.0 <= score_payload["credit_score"] <= 1000.0 + + drivers = client.post("/drivers", json=drivers_body) + assert drivers.status_code == 200, drivers.text + drivers_payload = drivers.json() + assert drivers_payload["method"] == "gain_x_abs_woe" + assert len(drivers_payload["drivers"]) == drivers_body["top_k"] + + +def test_prepare_uses_default_scoring_fixture_when_source_omitted( + tmp_path: Path, +) -> None: + """Operator script path: no ``source`` → DEFAULT_SCORING_FIXTURE.""" + from credit.demo_serve_artifact import ( + DEFAULT_SCORING_FIXTURE, + prepare_serve_demo_artifact, + ) + + assert DEFAULT_SCORING_FIXTURE.is_file() + artifact = prepare_serve_demo_artifact(tmp_path / "demo_default") + assert artifact == tmp_path / "demo_default" / ARTIFACT_NAME + assert artifact.is_file() + + +def test_prepare_raises_when_fit_skips_writing_joblib( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail clearly if fit returns without ``pipeline.joblib``.""" + from credit import demo_serve_artifact as mod + + monkeypatch.setattr( + mod, + "fit_scoring_pipeline", + lambda *a, **k: None, + ) + with pytest.raises(FileNotFoundError, match="Expected artifact missing"): + mod.prepare_serve_demo_artifact(tmp_path / "empty", source=SCORING_FIXTURE)