Skip to content

Major UX improvements for credit-scorer: simplified workflows, unified visualizations, and user-friendly defaults #236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 18, 2025
Merged
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
21 changes: 19 additions & 2 deletions credit-scorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,37 @@ zenml alerter register slack_alerter \
zenml stack update <STACK_NAME> -al slack_alerter
```

5. Set up Modal secrets for deployment (optional, only needed with `--enable-slack` flag):

```bash
# Create Modal secret with Slack credentials for incident reporting
modal secret create credit-scoring-secrets \
SLACK_BOT_TOKEN=<your_slack_bot_token> \
SLACK_CHANNEL_ID=<your_slack_channel_id>
```

> **Note:** The deployment pipeline uses Modal for cloud deployment. By default, Slack notifications are disabled for easier testing. The `credit-scoring-secrets` Modal secret stores the necessary Slack credentials for automated notifications when the deployed model API detects high or critical severity incidents.

> **Enabling full compliance features:** For complete EU AI Act compliance incident reporting (Article 18), use the `--enable-slack` flag (e.g., `python run.py --deploy --enable-slack`). This requires the Modal secret to be configured with your Slack credentials for automated incident notifications.

## πŸ“Š Running Pipelines

### Basic Commands

```bash
# Run complete workflow (recommended)
python run.py --all # Feature β†’ Training β†’ Deployment (auto-approved, no Slack)

# Run individual pipelines
python run.py --feature # Feature engineering (Articles 10, 12)
python run.py --train # Model training (Articles 9, 11, 15)
python run.py --deploy # Deployment (Articles 14, 17, 18)

# Pipeline options
python run.py --train --auto-approve # Skip manual approval steps
python run.py --feature --no-cache # Disable ZenML caching
python run.py --all --no-cache # Complete workflow without caching
python run.py --all --manual-approve # Complete workflow with manual approval steps
python run.py --deploy --config-dir ./my-configs # Custom config directory
python run.py --all --enable-slack # Complete workflow with Slack notifications (requires Modal secrets)
```

### View Compliance Dashboard
Expand Down
42 changes: 40 additions & 2 deletions credit-scorer/modal_app/modal_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,33 @@ def create_modal_app(python_version: str = "3.12.9"):

app_config = {
"image": base_image,
"secrets": [modal.Secret.from_name(SECRET_NAME)],
}

# Only add secrets if Slack notifications are explicitly enabled
enable_slack_raw = os.getenv("ENABLE_SLACK", "false").lower()
if enable_slack_raw not in {"true", "false"}:
logger.error(
f"Invalid value for ENABLE_SLACK: '{enable_slack_raw}'. Expected 'true' or 'false'. Deployment aborted."
)
raise ValueError(
f"Invalid ENABLE_SLACK value: '{enable_slack_raw}'. Deployment aborted."
)

enable_slack = enable_slack_raw == "true"
if enable_slack:
try:
app_config["secrets"] = [modal.Secret.from_name(SECRET_NAME)]
logger.info(f"Added secret {SECRET_NAME} to Modal app")
except Exception as e:
logger.warning(f"Could not add secret {SECRET_NAME}: {e}")
logger.info(
"Continuing without secrets - Slack notifications will be disabled"
)
else:
logger.info(
"Slack notifications disabled by default - Modal app created without secrets"
)

try:
volume = modal.Volume.from_name(VOLUME_NAME)
app_config["volumes"] = {"/mnt": volume}
Expand Down Expand Up @@ -167,7 +191,17 @@ def _report_incident(incident_data: dict, model_checksum: str) -> dict:
logger.warning(f"Could not write to local incident log: {e}")

# 2. Direct Slack notification for high/critical severity (not using ZenML)
if incident["severity"] in ("high", "critical"):
enable_slack_raw = os.getenv("ENABLE_SLACK", "false").lower()
if enable_slack_raw not in {"true", "false"}:
logger.error(
f"Invalid value for ENABLE_SLACK: '{enable_slack_raw}'. Expected 'true' or 'false'."
)
# Don't abort incident reporting, just skip Slack notification
enable_slack = False
else:
enable_slack = enable_slack_raw == "true"

if incident["severity"] in ("high", "critical") and enable_slack:
try:
slack_token = os.getenv("SLACK_BOT_TOKEN")
slack_channel = os.getenv("SLACK_CHANNEL_ID", SC.CHANNEL_ID)
Expand Down Expand Up @@ -209,6 +243,10 @@ def _report_incident(incident_data: dict, model_checksum: str) -> dict:
)
except Exception as e:
logger.warning(f"Failed to send Slack notification: {e}")
elif not enable_slack:
logger.info(
"Slack notifications disabled (use --enable-slack flag to enable)"
)

return {
"status": "reported",
Expand Down
55 changes: 34 additions & 21 deletions credit-scorer/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,32 @@
help="Directory containing configuration files.",
)
@click.option(
"--auto-approve",
"--manual-approve",
is_flag=True,
default=False,
help="Auto-approve deployment (for CI/CD pipelines).",
help="Require manual approval for deployment (disables auto-approve).",
)
@click.option(
"--no-cache",
is_flag=True,
default=False,
help="Disable caching for pipeline runs.",
)
@click.option(
"--enable-slack",
is_flag=True,
default=False,
help="Enable Slack notifications in deployment (requires Modal secrets setup).",
)
def main(
feature: bool = False,
train: bool = False,
deploy: bool = False,
all: bool = False,
config_dir: str = "src/configs",
auto_approve: bool = True,
manual_approve: bool = False,
no_cache: bool = False,
enable_slack: bool = False,
):
"""Main entry point for EU AI Act compliance pipelines.

Expand All @@ -115,19 +122,28 @@ def main(
if not config_dir.exists():
raise ValueError(f"Configuration directory {config_dir} not found")

# Handle auto-approve setting for deployment
# Handle approval setting for deployment (auto-approve is now default)
auto_approve = not manual_approve
if auto_approve:
os.environ["DEPLOY_APPROVAL"] = "y"
os.environ["APPROVER"] = "automated_ci"
os.environ["APPROVAL_RATIONALE"] = (
"Automatic approval via --auto-approve flag"
"Automatic approval (default behavior)"
)

# Handle Slack setting for deployment (Slack disabled by default)
if enable_slack:
os.environ["ENABLE_SLACK"] = "true"

# Common pipeline options
pipeline_args = {}
if no_cache:
pipeline_args["enable_cache"] = False

# Handle --all flag first
if all:
feature = train = deploy = True

# Track outputs for chaining pipelines
outputs = {}

Expand Down Expand Up @@ -162,15 +178,18 @@ def main(

train_args = {}

# Use outputs from previous pipeline if available
if "train_df" in outputs and "test_df" in outputs:
train_args["train_df"] = outputs["train_df"]
train_args["test_df"] = outputs["test_df"]
# Don't pass DataFrame artifacts directly - let training pipeline fetch them
# from artifact store via Client.get_artifact_version() as designed

training_pipeline = training.with_options(**pipeline_args)
model, eval_results, eval_visualization, risk_scores, *_ = (
training_pipeline(**train_args)
)
(
model,
eval_results,
eval_visualization,
risk_scores,
risk_visualization,
*_,
) = training_pipeline(**train_args)

# Store for potential chaining
outputs["model"] = model
Expand All @@ -188,21 +207,15 @@ def main(

deploy_args = {}

if "model" in outputs:
deploy_args["model"] = outputs["model"]
if "evaluation_results" in outputs:
deploy_args["evaluation_results"] = outputs["evaluation_results"]
if "risk_scores" in outputs:
deploy_args["risk_scores"] = outputs["risk_scores"]
if "preprocess_pipeline" in outputs:
deploy_args["preprocess_pipeline"] = outputs["preprocess_pipeline"]
# Don't pass artifacts directly - let deployment pipeline fetch them
# from artifact store via Client.get_artifact_version() as designed

deployment.with_options(**pipeline_args)(**deploy_args)

logger.info("βœ… Deployment pipeline completed")

# If no pipeline specified, show help
if not any([feature, train, deploy, all]):
if not any([feature, train, deploy]):
ctx = click.get_current_context()
click.echo(ctx.get_help())

Expand Down
9 changes: 6 additions & 3 deletions credit-scorer/src/constants/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ class StrEnum(str, Enum):
class Pipelines(StrEnum):
"""Pipeline names used in ZenML."""

FEATURE_ENGINEERING = "feature_engineering"
TRAINING = "training"
DEPLOYMENT = "deployment"
FEATURE_ENGINEERING = "credit_scoring_feature_engineering"
TRAINING = "credit_scoring_training"
DEPLOYMENT = "credit_scoring_deployment"


class Artifacts(StrEnum):
Expand All @@ -58,6 +58,7 @@ class Artifacts(StrEnum):
EVALUATION_RESULTS = "evaluation_results"
EVAL_VISUALIZATION = "evaluation_visualization"
RISK_SCORES = "risk_scores"
RISK_VISUALIZATION = "risk_visualization"
FAIRNESS_REPORT = "fairness_report"
RISK_REGISTER = "risk_register"

Expand All @@ -69,6 +70,8 @@ class Artifacts(StrEnum):
INCIDENT_REPORT = "incident_report"
COMPLIANCE_RECORD = "compliance_record"
SBOM_ARTIFACT = "sbom_artifact"
SBOM_HTML = "sbom_html"
ANNEX_IV_PATH = "annex_iv_path"
ANNEX_IV_HTML = "annex_iv_html"
RUN_RELEASE_DIR = "run_release_dir"
COMPLIANCE_DASHBOARD_HTML = "compliance_dashboard_html"
15 changes: 10 additions & 5 deletions credit-scorer/src/pipelines/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def deployment(
)

# Generate Software Bill of Materials for Article 15 (Accuracy & Robustness)
generate_sbom(
sbom_data, sbom_html = generate_sbom(
deployment_info=deployment_info,
)

Expand All @@ -103,10 +103,12 @@ def deployment(
)

# Generate comprehensive technical documentation (Article 11)
documentation_path, run_release_dir = generate_annex_iv_documentation(
evaluation_results=evaluation_results,
risk_scores=risk_scores,
deployment_info=deployment_info,
documentation_path, documentation_html, run_release_dir = (
generate_annex_iv_documentation(
evaluation_results=evaluation_results,
risk_scores=risk_scores,
deployment_info=deployment_info,
)
)

# Generate compliance dashboard HTML visualization
Expand All @@ -118,5 +120,8 @@ def deployment(
deployment_info,
monitoring_plan,
documentation_path,
documentation_html,
sbom_data,
sbom_html,
compliance_dashboard,
)
10 changes: 8 additions & 2 deletions credit-scorer/src/pipelines/training.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,17 @@ def training(
)

# Perform risk assessment based on evaluation results
risk_scores = risk_assessment(
risk_scores, risk_visualization = risk_assessment(
evaluation_results=eval_results,
risk_register_path=risk_register_path,
approval_thresholds=approval_thresholds,
)

# Return artifacts to be used by deployment pipeline
return model, eval_results, eval_visualization, risk_scores
return (
model,
eval_results,
eval_visualization,
risk_scores,
risk_visualization,
)
18 changes: 17 additions & 1 deletion credit-scorer/src/steps/deployment/approve.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

import os
import time
from datetime import datetime
from typing import Annotated, Any, Dict, Tuple
Expand Down Expand Up @@ -285,8 +286,17 @@ def send_slack_message(message, blocks, ask_question=False):
print("πŸ’‘ Fix: Use a Bot User OAuth Token (starts with xoxb-)")
return None

# Check for auto-approve from environment variables
auto_approve = os.environ.get("DEPLOY_APPROVAL", "").lower() == "y"
env_approver = os.environ.get("APPROVER", "")
env_rationale = os.environ.get("APPROVAL_RATIONALE", "")

# Send initial notification
header = "MODEL AUTO-APPROVED" if all_ok else "HUMAN REVIEW REQUIRED"
header = (
"MODEL AUTO-APPROVED"
if all_ok or auto_approve
else "HUMAN REVIEW REQUIRED"
)
send_slack_message(header, create_blocks("Model Approval"))

# Determine approval
Expand All @@ -296,6 +306,12 @@ def send_slack_message(message, blocks, ask_question=False):
"automated_system",
"All criteria met",
)
elif auto_approve:
approved, approver, rationale = (
True,
env_approver or "automated_ci",
env_rationale or "Auto-approved via environment variable",
)
else:
response = send_slack_message(
f"Override deployment for pipeline '{pipeline_name}'?",
Expand Down
Loading