diff --git a/03-Azure/01-04-AI/06_AI_Governance/README.md b/03-Azure/01-04-AI/06_AI_Governance/README.md index 81cb1c59d..a930df554 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/README.md +++ b/03-Azure/01-04-AI/06_AI_Governance/README.md @@ -67,9 +67,11 @@ Your lab automation deploys: the later challenges call for. Each challenge exercises one governance capability against these resources, using the -Citadel workshop's own Jupyter notebooks — unmodified, and already fully documented with -their own numbered sections, so the challenge files here exist to orient you and point -you at the right notebook, not to restate what's already in it. +Citadel workshop's own Jupyter notebooks — kept as close to upstream as possible, and +already fully documented with their own numbered sections, so the challenge files here +exist to orient you and point you at the right notebook, not to restate what's already +in it. (The only change made to them is in each notebook's first setup cell, so that +configuration can be read from the environment as well as from `azd`.) ## Requirements @@ -77,19 +79,29 @@ To complete this MicroHack you'll need: - Your lab's attendee credentials (`HackboxCredential` values) from the MicroHack dashboard — resource group, subscription, and the Spoke Foundry/Key Vault/ACR names. -- `azd` installed, so [`setup-notebook-env.ps1`](labautomation/README.md#notebook-environment-setup) - can bridge those credentials into a local `azd` environment the (unchanged) workshop - notebooks read via `azd env get-value`. - The Azure CLI (`az`), logged in (`az login`) against your lab's subscription/tenant. - The workshop's Python environment set up (`uv sync` — see [`challenges/workshop/readme.md`](challenges/workshop/readme.md) — or `pip install -r requirements.txt` from the `challenges/workshop/` folder). - Comfort running and reading Jupyter notebooks in VS Code. +`azd` is **optional**. The notebooks read their configuration from the environment +first, so you can simply paste your dashboard values into a `.env` file: + +```bash +cd challenges/workshop +cp .env.template .env # then fill in the values from your dashboard +``` + +If you'd rather use the original `azd`-based workflow, install `azd` and run +[`setup-notebook-env.ps1`](labautomation/README.md#notebook-environment-setup) +instead — the notebooks fall back to `azd env get-value` for any key that isn't +already set in the environment. Both routes work; you only need one. + > [!TIP] -> **You're ready to start when** you've run `setup-notebook-env.ps1` once with your -> dashboard's credentials (see [Challenge 1, Part A](challenges/challenge-01.md)) and can -> open the first workshop notebook without any `azd env get-value` errors. +> **You're ready to start when** `challenges/workshop/.env` holds your dashboard +> values (see [Challenge 1, Part A](challenges/challenge-01.md)) and you can open the +> first workshop notebook and run its setup cell without errors. ## Challenges diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-01.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-01.md index 3f4318146..c621ce92a 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-01.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-01.md @@ -13,19 +13,46 @@ fragments, then prove it works through multiple API formats. This challenge runs entirely against **Hub** resources — the APIM instance and its backend pools sit in front of every model your governance hub serves. Before you start, -confirm your attendee credentials have been bridged into a local `azd` environment (see -Part A below); every workshop notebook reads its configuration via `azd env get-value`. +confirm your attendee credentials are available to the notebooks (see Part A below); +every workshop notebook reads its configuration from the environment, falling back to +`azd env get-value`. ## ✅ Tasks ### Part A — Confirm your notebook environment is ready (5 min) -1. Check with your facilitator (or your own setup) that +Every notebook in this MicroHack reads its settings from the environment, so do this +once and it applies to all nine challenges. Pick **either** route: + +**Route 1 — `.env` file (recommended, no extra tooling):** + +1. From the `challenges/workshop/` folder, copy the template: + + ```bash + cd challenges/workshop + cp .env.template .env + ``` + +2. Open `.env` and paste in the matching `HackboxCredential` values from your MicroHack + dashboard. The template names the exact dashboard credential for every key. + For challenges 1-6 you only need `AZURE_RESOURCE_GROUP`, `AZURE_LOCATION`, + `AZURE_SUBSCRIPTION_ID` and `LLM_BACKEND_CONFIG`. +3. Paste `LLM_BACKEND_CONFIG` as a **single line**, exactly as the dashboard shows it — + it's a JSON document, so a stray line break will break it. + +`.env` is gitignored, so your credentials stay out of source control. + +**Route 2 — `azd` bridge (the original workshop workflow):** + +1. Install `azd`, then run [`setup-notebook-env.ps1`](../labautomation/README.md#notebook-environment-setup) - has already been run with your dashboard's `HackboxCredential` values. -2. If it hasn't, run it now — the workshop notebooks are **unchanged** and call - `azd env get-value` for every setting, so this step is required once before any - notebook in this MicroHack. + with your dashboard's `HackboxCredential` values. It writes a local `azd` + environment that the notebooks fall back to for any key not already set. + +> [!TIP] +> To confirm you're ready, run the first setup cell of notebook 1. It prints your +> resource group, location, and the number of configured LLM backends. If it raises +> instead, the message tells you which key is missing and whether a `.env` was found. ### Part B — Run the LLM backend onboarding notebook (30 min) @@ -61,7 +88,8 @@ Part A below); every workshop notebook reads its configuration via `azd env get- | Symptom | Fix | |---------|-----| -| `azd env get-value` errors / empty values | `setup-notebook-env.ps1` hasn't been run for your attendee credentials yet — see Part A. | +| Setup cell raises a missing-key error | That key isn't in your `.env` (and no `azd` environment supplied it) — see Part A. The error message tells you which key is missing and whether a `.env` file was found. | +| `LLM_BACKEND_CONFIG` fails to parse | It must be on a single line, pasted exactly as the dashboard shows it. A line break or a truncated copy will break the JSON. | | Backend deployment succeeds but test calls 401/403 | APIM RBAC / managed-identity propagation can take a minute after a fresh deployment — wait and retry. | | A model call fails with a region/quota error | Model availability is region-specific; double-check the model/SKU you configured is available in your lab's region. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-02.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-02.md index fc3607e45..0c8c88e78 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-02.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-02.md @@ -13,17 +13,26 @@ right operation for each model (chat completions, embeddings, or the Responses A This challenge deploys its own **Access Contract** (an APIM product + subscription) via Bicep — a dynamically generated product policy with no model RBAC restriction and a -generous capacity allocation. Make sure your `setup-notebook-env.ps1` bridge is already +generous capacity allocation. Make sure your notebook configuration is already in place (see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min)) before you start. ## ✅ Tasks +> [!IMPORTANT] +> **Challenge 1 must be completed first — not just its environment setup.** +> Notebook 1 deploys the APIM **policy fragments** (`set-llm-requested-model`, +> `validate-model-access`, `set-backend-pools`, `set-target-backend-pool`, +> `set-backend-authorization`, `set-llm-usage`) that this challenge's product +> policy includes with ``. The lab's hub deployment +> intentionally ships only a minimal gateway, so if you jump straight here the +> Bicep deployment fails with a *"Policy fragment not found"* error. + ### Part A — Confirm your notebook environment is ready (2 min) -Same prerequisite as every notebook in this MicroHack: confirm -[`setup-notebook-env.ps1`](../labautomation/README.md#notebook-environment-setup) has -been run for your attendee credentials. +Same prerequisite as every notebook in this MicroHack: confirm your attendee +credentials are in `challenges/workshop/.env` (or bridged via `azd`) — see +[Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). ### Part B — Run the Universal LLM API validation notebook (25 min) @@ -59,7 +68,7 @@ been run for your attendee credentials. | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | A model in the catalogue fails every operation | Check whether that backend was onboarded in [Challenge 1](challenge-01.md) or is otherwise disabled in APIM. | | Responses API calls fail for a `gpt` model | Some models don't implement the Responses API trio — the notebook's classification is a heuristic based on the model name, not a guarantee. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-03.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-03.md index 4960d7709..6014588a5 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-03.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-03.md @@ -19,12 +19,20 @@ explore optional Key Vault and Microsoft Foundry connection integrations. The Foundry connection name(s) this notebook prints are consumed by [Challenge 4](challenge-04.md), so keep this notebook's output around before moving on. -As always, confirm -[`setup-notebook-env.ps1`](../labautomation/README.md#notebook-environment-setup) has -already been run. +As always, confirm your notebook configuration is in place — see +[Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). ## ✅ Tasks +> [!IMPORTANT] +> **Challenge 1 must be completed first — not just its environment setup.** +> Notebook 1 deploys the APIM **policy fragments** (`set-llm-requested-model`, +> `validate-model-access`, `set-backend-pools`, `set-target-backend-pool`, +> `set-backend-authorization`, `set-llm-usage`) that this challenge's product +> policy includes with ``. The lab's hub deployment +> intentionally ships only a minimal gateway, so if you jump straight here the +> Bicep deployment fails with a *"Policy fragment not found"* error. + ### Part A — Confirm your notebook environment is ready (2 min) Same prerequisite as every notebook in this MicroHack — see @@ -64,9 +72,9 @@ Same prerequisite as every notebook in this MicroHack — see | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | Key Vault integration fails to resolve a secret | Confirm the spoke Key Vault name resolved correctly and your identity has data-plane access (RBAC propagation can take a minute). | -| Foundry connection step fails | Confirm the spoke Foundry account/project names resolved from `azd env get-value` — re-check `setup-notebook-env.ps1` was run with the correct `SpokeAiFoundryAccountName` / `SpokeAiFoundryProjectName` values. | +| Foundry connection step fails | Confirm `SPOKE_AI_FOUNDRY_ACCOUNT_NAME` and `SPOKE_AI_FOUNDRY_PROJECT_NAME` match the `SpokeAiFoundryAccountName` / `SpokeAiFoundryProjectName` credentials on your dashboard. | | Load test results look flat (no 429s) | Capacity limits are per-contract; make sure you're hitting the right subscription key for the contract you're testing. | ## 🚀 Go further diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-05.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-05.md index 90bf0a66d..ebf2858a1 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-05.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-05.md @@ -19,6 +19,15 @@ don't need to provision anything extra beyond confirming your notebook environme ## ✅ Tasks +> [!IMPORTANT] +> **Challenge 1 must be completed first — not just its environment setup.** +> Notebook 1 deploys the APIM **policy fragments** (`set-llm-requested-model`, +> `validate-model-access`, `set-backend-pools`, `set-target-backend-pool`, +> `set-backend-authorization`, `set-llm-usage`) that this challenge's product +> policy includes with ``. The lab's hub deployment +> intentionally ships only a minimal gateway, so if you jump straight here the +> Bicep deployment fails with a *"Policy fragment not found"* error. + ### Part A — Confirm your notebook environment is ready (2 min) Same prerequisite as every notebook in this MicroHack — see @@ -56,7 +65,7 @@ Same prerequisite as every notebook in this MicroHack — see | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | Cosmos DB query returns nothing / `Forbidden` | Section `5️⃣.1.1` grants your identity Cosmos DB data-plane access — re-run it and wait a minute for RBAC to propagate. | | PII detection doesn't fire on a sample | Confirm the Azure AI Language Service instance is configured and reachable; some PII types need the exact regex/entity category the notebook defines. | | Deanonymization returns masked text unchanged | The masking/deanonymization state must be saved via Event Hub first — confirm section `3️⃣.5` ran before attempting deanonymization. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-06.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-06.md index facf66a57..c6407bf70 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-06.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-06.md @@ -19,6 +19,15 @@ start. ## ✅ Tasks +> [!IMPORTANT] +> **Challenge 1 must be completed first — not just its environment setup.** +> Notebook 1 deploys the APIM **policy fragments** (`set-llm-requested-model`, +> `validate-model-access`, `set-backend-pools`, `set-target-backend-pool`, +> `set-backend-authorization`, `set-llm-usage`) that this challenge's product +> policy includes with ``. The lab's hub deployment +> intentionally ships only a minimal gateway, so if you jump straight here the +> Bicep deployment fails with a *"Policy fragment not found"* error. + ### Part A — Confirm your notebook environment is ready (2 min) Same prerequisite as every notebook in this MicroHack — see @@ -69,7 +78,7 @@ Same prerequisite as every notebook in this MicroHack — see | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | Test 3 (Responses API) or Test 4 (Gemini) show as skipped | These are conditional on your onboarded backends supporting that pattern — not every lab environment configures a Gemini backend. | | Load test (Test 6) shows only `200`s, no `429`s | Capacity limits are per-contract; the load may not be high enough to trip the token bucket — increase the burst size in that cell. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-07.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-07.md index e8f63a4df..a63324f9a 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-07.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-07.md @@ -24,9 +24,10 @@ questions. **This notebook requires [Challenge 3](challenge-03.md)** to have created the `Hub-HR-ChatAgent-DEV-LLM` BYO Gateway connection. Your lab's spoke Foundry resources and -Azure Container Registry are already provisioned — check -[`setup-notebook-env.ps1`](../labautomation/README.md#notebook-environment-setup) has -run so the notebook can resolve them. +Azure Container Registry are already provisioned — make sure `SPOKE_ACR_NAME` and +`SPOKE_ACR_LOGIN_SERVER` are set (see +[Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min)) +so the notebook can resolve them. ## ✅ Tasks @@ -72,7 +73,7 @@ Same prerequisite as every notebook in this MicroHack — see | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | Connection `Hub-HR-ChatAgent-DEV-LLM` not found | Run [Challenge 3](challenge-03.md) first — it creates this Foundry connection. | | Agent stays in a non-active state after step `5️⃣` | Container deployments can take a few minutes to come up; re-run the wait cell rather than assuming failure. | | Step `6a` doesn't deny the disallowed request | Confirm `policies/hr-policy.yaml` was actually bundled into the container image built in step `3️⃣` — re-run `2️⃣`–`4️⃣` if the source files changed after the last build. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-08.md b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-08.md index 38e1a0e87..e01950b08 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-08.md +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/challenge-08.md @@ -13,7 +13,7 @@ disabled. This notebook: -1. Resolves the agent-hosting Foundry account (the `-0` account) and project from `azd` — +1. Resolves the agent-hosting Foundry account and project from the environment — no hardcoded names. 2. Temporarily enables public network access on that account so the notebook can reach its data plane. @@ -26,6 +26,13 @@ This notebook: 7. Calls the agent through APIM with just a subscription key — proving APIM reaches it over its private endpoint even with public access disabled. +> [!IMPORTANT] +> Upstream, this notebook finds the agent-hosting account by looking for a backend id +> ending in `-0`. This lab names its Foundry accounts `aif-hub-*` / `aif-spoke-*`, so +> that lookup finds nothing. Set **`A2A_FOUNDRY_ACCOUNT_NAME`** in your +> `challenges/workshop/.env` to the **`A2aFoundryAccountName`** value from your +> dashboard (it's the spoke Foundry account) and the notebook resolves it directly. + ## ✅ Tasks ### Part A — Confirm your notebook environment is ready (2 min) @@ -71,7 +78,7 @@ Same prerequisite as every notebook in this MicroHack — see | Symptom | Fix | |---------|-----| -| `azd env get-value` errors | `setup-notebook-env.ps1` hasn't been run — see Part A. | +| Setup cell raises a missing-key error | Your `challenges/workshop/.env` is missing that key (or you haven't run the `azd` bridge) — see [Challenge 1, Part A](challenge-01.md#part-a--confirm-your-notebook-environment-is-ready-5-min). | | `500` — backend "name is valid, but no data of the requested type was found" | A DNS/network error: APIM can't reach the agent's Foundry host. Confirm you're publishing through the APIM in the **same** azd environment as the agent. | | `500` with a managed-identity error in the trace | APIM had no usable identity — section `10.3` auto-detects system- vs. user-assigned identity; if user-assigned, confirm the `client-id` was pinned. | | `401`/`403` from the backend | The APIM managed identity lacks an Azure AI role (e.g. **Cognitive Services User**) on the agent's Foundry account — `azd up` grants this by default, but role propagation can take a few minutes. | diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/.env.template b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/.env.template new file mode 100644 index 000000000..4e0a0f36d --- /dev/null +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/.env.template @@ -0,0 +1,64 @@ +# --------------------------------------------------------------------------- +# MicroHack: AI Governance — notebook configuration +# +# Copy this file to `.env` in this same folder, then paste in the values from +# the "Credentials" panel of your MicroHack dashboard. The notebooks read +# os.environ first, so this is all you need — `azd` is NOT required. +# +# cp .env.template .env +# +# `.env` is gitignored, so your values will not be committed. +# +# (If you prefer the original azd workflow, leave this file alone and run +# labautomation/setup-notebook-env.ps1 instead — the notebooks fall back to +# `azd env get-value` when a key is not present here.) +# --------------------------------------------------------------------------- + +# ===== Required for challenges 1-6 (core) ================================== +# Dashboard credential: ResourceGroup +AZURE_RESOURCE_GROUP= + +# Dashboard credential: Location +AZURE_LOCATION= + +# Dashboard credential: SubscriptionId +AZURE_SUBSCRIPTION_ID= + +# Dashboard credential: LlmBackendConfig +# One long line of JSON — paste it exactly as shown, with no line breaks +# and no surrounding quotes. +LLM_BACKEND_CONFIG= + +# ===== Required for challenges 3, 4 and 7 ================================== +# Dashboard credential: SpokeResourceGroup +# (Same value as AZURE_RESOURCE_GROUP — this lab uses a single resource group.) +SPOKE_RESOURCE_GROUP= + +# Dashboard credential: SpokeKeyVaultName +SPOKE_KEY_VAULT_NAME= + +# Dashboard credential: SpokeAiFoundryAccountName +SPOKE_AI_FOUNDRY_ACCOUNT_NAME= + +# Dashboard credential: SpokeAiFoundryProjectName +SPOKE_AI_FOUNDRY_PROJECT_NAME= + +# ===== Required for challenge 7 ============================================ +# Dashboard credential: SpokeAcrName +SPOKE_ACR_NAME= + +# Dashboard credential: SpokeAcrLoginServer +SPOKE_ACR_LOGIN_SERVER= + +# ===== Required for challenge 8 ============================================ +# Dashboard credential: A2aFoundryAccountName +# The Foundry account that hosts the agent. Set this explicitly — challenge 8 +# otherwise looks for a backend id ending in "-0", which this lab's resource +# naming does not produce. +A2A_FOUNDRY_ACCOUNT_NAME= + +# ===== Challenge 9 (optional / instructor-led) ============================= +# Not produced by this lab's automation — your facilitator will provide these +# only if challenge 9 is being run. +HR_MCP_ACCESS_TOKEN= +HR_MCP_APIM_SUBSCRIPTION_KEY= diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/1. llm-backend-onboarding-runner.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/1. llm-backend-onboarding-runner.ipynb index 9d3221154..85682b3a7 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/1. llm-backend-onboarding-runner.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/1. llm-backend-onboarding-runner.ipynb @@ -50,12 +50,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/2. citadel-universal-llm-api-all-models-tests.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/2. citadel-universal-llm-api-all-models-tests.ipynb index d577f94b6..c743abbed 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/2. citadel-universal-llm-api-all-models-tests.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/2. citadel-universal-llm-api-all-models-tests.ipynb @@ -59,12 +59,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/3. citadel-access-contracts-tests.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/3. citadel-access-contracts-tests.ipynb index 43c30f628..99c36b0fd 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/3. citadel-access-contracts-tests.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/3. citadel-access-contracts-tests.ipynb @@ -66,12 +66,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/4. citadel-agent-frameworks-tests.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/4. citadel-agent-frameworks-tests.ipynb index 8f3524afc..2057d23b1 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/4. citadel-agent-frameworks-tests.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/4. citadel-agent-frameworks-tests.ipynb @@ -52,12 +52,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/5. citadel-pii-processing-tests.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/5. citadel-pii-processing-tests.ipynb index 6a61cebcb..405b60132 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/5. citadel-pii-processing-tests.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/5. citadel-pii-processing-tests.ipynb @@ -55,12 +55,55 @@ "from apimtools import APIMClientTool\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/6. citadel-unified-ai-api-tests.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/6. citadel-unified-ai-api-tests.ipynb index 362d84874..d277a0219 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/6. citadel-unified-ai-api-tests.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/6. citadel-unified-ai-api-tests.ipynb @@ -52,12 +52,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/7. citadel-hosted-agent-with-agt.ipynb b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/7. citadel-hosted-agent-with-agt.ipynb index 246239ee9..1d18f6dbf 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/7. citadel-hosted-agent-with-agt.ipynb +++ b/03-Azure/01-04-AI/06_AI_Governance/challenges/workshop/7. citadel-hosted-agent-with-agt.ipynb @@ -54,12 +54,55 @@ "# Read environment values from azd\n", "import subprocess, json\n", "\n", + "# --- MicroHack: run against a pre-provisioned lab, with or without azd ---\n", + "# Configuration resolves from os.environ first (populate it from a .env file\n", + "# or your shell using the credentials shown on your MicroHack dashboard), and\n", + "# falls back to `azd env get-value` so the original azd workflow still works.\n", + "import os\n", + "from pathlib import Path\n", + "\n", + "\n", + "def _microhack_load_dotenv():\n", + " \"\"\"Load .env from this folder or any parent. No third-party dependency.\"\"\"\n", + " for folder in [Path.cwd(), *Path.cwd().parents]:\n", + " candidate = folder / \".env\"\n", + " if not candidate.is_file():\n", + " continue\n", + " for raw in candidate.read_text(encoding=\"utf-8\").splitlines():\n", + " line = raw.strip()\n", + " if not line or line.startswith(\"#\") or \"=\" not in line:\n", + " continue\n", + " key, _, value = line.partition(\"=\")\n", + " key = key.strip()\n", + " value = value.strip()\n", + " if len(value) > 1 and value[0] == value[-1] and value[0] in \"\\\"'\":\n", + " value = value[1:-1]\n", + " if key and value and key not in os.environ:\n", + " os.environ[key] = value\n", + " return candidate\n", + " return None\n", + "\n", + "\n", + "_microhack_dotenv = _microhack_load_dotenv()\n", + "# ------------------------------------------------------------------------\n", + "\n", "def azd_get_value(key):\n", - " \"\"\"Read a value from the current azd environment.\"\"\"\n", - " result = subprocess.run(\n", - " [\"azd\", \"env\", \"get-value\", key],\n", - " capture_output=True, text=True\n", - " )\n", + " \"\"\"Read a config value: os.environ (.env or shell) first, then azd.\"\"\"\n", + " value = os.environ.get(key)\n", + " if value and value.strip():\n", + " return value.strip()\n", + " try:\n", + " result = subprocess.run(\n", + " [\"azd\", \"env\", \"get-value\", key],\n", + " capture_output=True, text=True\n", + " )\n", + " except FileNotFoundError:\n", + " hint = f\"loaded {_microhack_dotenv}\" if _microhack_dotenv else \"no .env file found\"\n", + " raise RuntimeError(\n", + " f\"{key} is not set and azd is not installed ({hint}). Copy \"\n", + " f\".env.template to .env and fill it in with the values from your \"\n", + " f\"MicroHack dashboard.\"\n", + " )\n", " if result.returncode != 0:\n", " raise RuntimeError(f\"Failed to read {key}: {result.stderr.strip()}\")\n", " return result.stdout.strip()\n", diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/README.md b/03-Azure/01-04-AI/06_AI_Governance/labautomation/README.md index 03598603b..7e74fc233 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/README.md +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/README.md @@ -84,13 +84,13 @@ Two details are load-bearing for **challenge 7**, which builds a hosted agent co - The ACR has **`adminUserEnabled: false`**. The notebook builds with `az acr build` (cloud build, no local Docker) and the agent pulls via managed identity, so admin credentials are never needed. `SPOKE_RESOURCE_GROUP` is emitted as the same value as `ResourceGroup`, which - is what the unchanged notebooks read. + is what the notebooks read. ## What the Attendee Receives The platform dashboard surfaces these credentials as `HackboxCredential` objects. The **Notebook azd Key** column is what the corresponding value must be set to via -`setup-notebook-env.ps1` for the unchanged workshop notebooks to consume it via +`setup-notebook-env.ps1` for the workshop notebooks to consume it via `azd env get-value` (see [Notebook Environment Setup](#notebook-environment-setup) below). | Credential | Used For | Notebook azd Key | @@ -120,10 +120,18 @@ dashboard label matches the `SPOKE_AI_FOUNDRY_*` key the notebooks expect. ## Notebook Environment Setup -The workshop notebooks are unchanged and call `azd env get-value ` for their +The workshop notebooks read their settings from the environment first and fall back +to `azd env get-value ` for their configuration — but this lab deploys with `deploy-lab.ps1` (PowerShell/Bicep), not `azd up`, so there's no azd environment for them to read by default. +> [!NOTE] +> This script is **optional**. Because the notebooks now read `os.environ` first, the +> simpler path for attendees is to copy `challenges/workshop/.env.template` to `.env` +> and paste in their dashboard values — no `azd` install required. Use this script when +> you want the original azd-based workflow, or to provision attendees' environments +> programmatically. + Run [`setup-notebook-env.ps1`](setup-notebook-env.ps1) once per attendee, using the `HackboxCredential` values from the dashboard, to bridge the gap: @@ -249,11 +257,28 @@ must reflect the real burn rate. Breakdown per lab: > roughly 7×, but it changes gateway capabilities — treat that as a functional decision, not a > pure cost tweak. -**`labsPerSubscription: 8`** — bounded by Azure OpenAI regional quota, not cost: -`8 labs × 100 capacity units per model = 800` units, inside the typical 1 000-unit -GlobalStandard quota per model per region. **If you raise `labsPerSubscription`, or raise the -model `capacity` in `infra/resources.bicep`, re-check that product against the subscription's -quota in the target region** — exceeding it makes later labs fail to deploy their models. +**`labsPerSubscription: 8`** — bounded by Azure OpenAI regional quota, not cost. +The per-model cost of a full subscription is `labsPerSubscription × modelCapacity`, +against a typical **1 000-unit** GlobalStandard quota per model per region. + +The `modelCapacity` parameter in `infra/resources.bicep` defaults to **20** +(20 K TPM per model per attendee), giving `8 × 20 = 160` units per model — +comfortably inside quota even when other labs share the subscription. 20 K TPM is +far more than the notebooks need; the earlier value of 100 exhausted the +`text-embedding-3-large` quota before all 8 labs could deploy. + +**Before an event, verify the target region actually has the headroom:** + +```bash +az cognitiveservices usage list -l -o table \ + | grep -E 'GlobalStandard\.(gpt4\.1|gpt-5\.2|gpt-5\.4-mini|text-embedding-3-large|Mistral-Large-3|Phi-4)' +``` + +For each row confirm `limit - currentValue >= labsPerSubscription × modelCapacity` +(Phi-4 uses capacity 1, so it needs only `labsPerSubscription`). Note that the +per-model quota row does not appear for a model until one deployment of it exists +in that region. **If you raise `labsPerSubscription` or `modelCapacity`, re-check +this** — exceeding quota makes later labs fail to deploy their models. `estimatedSharedDeploymentDailyCostsUsd: 0.0` — `shared-deploy-lab.ps1` only registers resource providers and deploys nothing billable. diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/deploy-lab.ps1 b/03-Azure/01-04-AI/06_AI_Governance/labautomation/deploy-lab.ps1 index d5d29e472..463160d8e 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/deploy-lab.ps1 @@ -63,10 +63,25 @@ $ErrorActionPreference = "Stop" $candidateRegions = if ($PreferredLocation.Count -gt 0) { $PreferredLocation } else { @("swedencentral", "westeurope", "norwayeast") } $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition -$bicepFile = Join-Path $scriptPath 'infra/resources.bicep' -if (-not (Test-Path $bicepFile)) { - throw "Bicep template not found at '$bicepFile'. Ensure infra/resources.bicep exists." +# Prefer the pre-compiled ARM template. New-AzResourceGroupDeployment -TemplateFile *.bicep +# shells out to the standalone Bicep CLI, which is NOT guaranteed to exist on the platform +# deployment host (the az-CLI-bundled bicep in ~/.azure/bin is invisible to Az PowerShell). +# infra/resources.json is committed alongside the Bicep source precisely so deployment has no +# local toolchain dependency; fall back to the .bicep source only if it is missing. +$armFile = Join-Path $scriptPath 'infra/resources.json' +$bicepSrc = Join-Path $scriptPath 'infra/resources.bicep' + +if (Test-Path $armFile) { + $templateFile = $armFile + $templateKind = 'compiled ARM (infra/resources.json)' +} +elseif (Test-Path $bicepSrc) { + $templateFile = $bicepSrc + $templateKind = 'Bicep source (infra/resources.bicep, requires Bicep CLI)' +} +else { + throw "No deployment template found. Expected 'infra/resources.json' (preferred) or 'infra/resources.bicep' under '$scriptPath'." } # --- Resolve the resource group per the platform contract ------------------- @@ -108,17 +123,17 @@ if ($AllowedEntraUserIds.Count -eq 0) { } Write-Host "[INFO] Deploying Citadel Agentic Governance Hub + Spoke into RG '$effectiveRG'..." -Write-Host "[INFO] Engine: Bicep (infra/resources.bicep), resource-group-scoped." +Write-Host "[INFO] Engine: $templateKind, resource-group-scoped." $deployOutputs = $null $effectiveLocation = $null foreach ($region in $candidateRegions) { - Write-Host "[INFO] Deploying Bicep → RG '$effectiveRG' in '$region' (token '$resourceToken')..." + Write-Host "[INFO] Deploying → RG '$effectiveRG' in '$region' (token '$resourceToken')..." try { $d = New-AzResourceGroupDeployment ` -ResourceGroupName $effectiveRG ` - -TemplateFile $bicepFile ` + -TemplateFile $templateFile ` -location $region ` -resourceToken $resourceToken ` -tags $tags ` @@ -136,22 +151,36 @@ foreach ($region in $candidateRegions) { $retryable = [bool](Test-MhhDeploymentFailureRetryable -ErrorRecord $_) } if (-not $retryable) { - throw "Bicep deployment failed in '$region' with a non-retryable error (retrying other regions would fail the same way): $_" + throw "Deployment failed in '$region' with a non-retryable error (retrying other regions would fail the same way): $_" } - Write-Host "[WARN] Bicep deployment failed in '$region': $_ — trying next region." + Write-Host "[WARN] Deployment failed in '$region': $_ — trying next region." } } if (-not $deployOutputs) { - throw "Bicep deployment failed in all candidate regions: $($candidateRegions -join ', ')" + throw "Deployment failed in all candidate regions: $($candidateRegions -join ', ')" } Write-Host "[OK] Provisioning complete in '$effectiveLocation' (resource group '$effectiveRG')." # --- Deployment output helper (used by the RBAC block and the dashboard block) --- +# ARM does NOT preserve the casing of output names. A template output declared as +# APIM_GATEWAY_URL comes back from Azure as 'apiM_GATEWAY_URL' (ARM lowercases the leading +# run of capitals, keeping only the last one), and APPLICATIONINSIGHTS_CONNECTION_STRING +# comes back as 'applicationinsightS_CONNECTION_STRING'. The returned collection is a +# case-SENSITIVE dictionary, so a literal ContainsKey($Key) misses every single output — +# which silently blanks both participant RBAC and every dashboard credential. +# Normalising both sides to upper case is exact: mangling only ever changes letter casing. function Get-OutVal { param($Outputs, [string]$Key) - if ($Outputs -and $Outputs.ContainsKey($Key)) { return "$($Outputs[$Key].Value)" } + if (-not $Outputs) { return '' } + + if ($Outputs.ContainsKey($Key)) { return "$($Outputs[$Key].Value)" } + + $target = $Key.ToUpperInvariant() + foreach ($k in $Outputs.Keys) { + if ($k.ToUpperInvariant() -eq $target) { return "$($Outputs[$k].Value)" } + } return '' } @@ -193,9 +222,12 @@ $spokeAiId = Get-OutVal $deployOutputs 'SPOKE_APP_INSIGHTS_RESOURCE_ID' $apimResourceId = Get-OutVal $deployOutputs 'APIM_RESOURCE_ID' $acrId = Get-OutVal $deployOutputs 'SPOKE_ACR_RESOURCE_ID' -# RBAC role IDs (built-in) +# RBAC role IDs (built-in). Matched by GUID, never by display name: Azure has since renamed +# 'Azure AI User' to 'Foundry User' and 'Azure AI Project Manager' to 'Foundry Project Manager'. +# The GUIDs are stable, so the grants still work — but expect the NEW names in the portal and +# in `az role assignment list` when verifying a participant's access. $foundryRoles = [ordered]@{ - 'Azure AI User' = '53ca6127-db72-4b80-b1b0-d745d6d5456d' + 'Azure AI User' = '53ca6127-db72-4b80-b1b0-d745d6d5456d' # portal: 'Foundry User' 'Cognitive Services User' = 'a97b65f3-24c7-4388-baec-2e87135dc908' } @@ -207,11 +239,14 @@ $foundryRoles = [ordered]@{ # Optional target so a tenant that restricts this role fails challenges 7-9 only, rather # than failing the whole provision for the core challenges 1-6. $spokeProjectMgmtRoles = [ordered]@{ - 'Azure AI Project Manager' = 'eadc314b-1a2d-4efa-be10-5d325db5065e' + 'Azure AI Project Manager' = 'eadc314b-1a2d-4efa-be10-5d325db5065e' # portal: 'Foundry Project Manager' } $keyVaultRoles = [ordered]@{ - 'Key Vault Secrets Officer' = 'b86a8fe4-44ce-4948-aee5-eccb2c155090' + # Verified against the live built-in role definition. Do not "correct" this GUID by hand: + # the suffix is ...155cd7, NOT ...155090 (an earlier typo here silently failed every + # Key Vault grant with RoleDefinitionDoesNotExist). + 'Key Vault Secrets Officer' = 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7' } $monitoringRoles = [ordered]@{ @@ -530,12 +565,13 @@ if ($piiAnalyticsId) { } if ($llmBackendConfig) { - @{ HackboxCredential = @{ name = 'LlmBackendConfig'; value = $llmBackendConfig; note = 'Base64-encoded LLM backend routing config. Maps to notebook azd env key LLM_BACKEND_CONFIG' } } + @{ HackboxCredential = @{ name = 'LlmBackendConfig'; value = $llmBackendConfig; note = 'LLM backend routing config as raw JSON. Maps to notebook azd env key LLM_BACKEND_CONFIG' } } } if ($spokeFoundryAccountNameOut) { @{ HackboxCredential = @{ name = 'SpokeFoundryAccountName'; value = $spokeFoundryAccountNameOut; note = 'Spoke Foundry account name (Notebooks 7-9)' } } @{ HackboxCredential = @{ name = 'SpokeAiFoundryAccountName'; value = $spokeFoundryAccountNameOut; note = 'Alias of SpokeFoundryAccountName. Maps to notebook azd env key SPOKE_AI_FOUNDRY_ACCOUNT_NAME' } } + @{ HackboxCredential = @{ name = 'A2aFoundryAccountName'; value = $spokeFoundryAccountNameOut; note = 'Agent-hosting Foundry account for Challenge 8. Maps to notebook env key A2A_FOUNDRY_ACCOUNT_NAME' } } } if ($spokeFoundryProjectNameOut) { @{ HackboxCredential = @{ name = 'SpokeFoundryProjectName'; value = $spokeFoundryProjectNameOut; note = 'Spoke Foundry project name (Notebooks 7-9)' } } diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.bicep b/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.bicep index 3b8c55e9c..406ce57cf 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.bicep +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.bicep @@ -25,6 +25,17 @@ param resourceToken string @description('Tags to apply to all resources.') param tags object = {} +@minValue(1) +@maxValue(500) +@description('''TPM capacity (thousands of tokens/min) for each chat + embedding model +deployment. Sized so that a full subscription of labs fits inside the default +Cognitive Services GlobalStandard quota: the platform packs `labsPerSubscription` +(8) labs into one subscription and region, so the per-region cost of this lab is +`modelCapacity x 5 x 8`. At the default of 20 that is 800 of the typical 1000-unit +per-model quota, leaving headroom for other labs sharing the subscription. Raise it +only after confirming quota with `az cognitiveservices usage list -l `.''') +param modelCapacity int = 20 + // ===== Hub Resource Naming ===== var hubFoundryAccountName = 'aif-hub-${resourceToken}' var hubFoundryProjectName = 'citadel-hub-project' @@ -218,40 +229,40 @@ var aiFoundryModels = [ publisher: 'OpenAI' version: '2025-04-14' sku: 'GlobalStandard' - capacity: 100 - retirementDate: '2026-10-14' + capacity: modelCapacity + retirementDate: '2027-04-14' } { name: 'gpt-5.4-mini' publisher: 'OpenAI' version: '2026-03-17' sku: 'GlobalStandard' - capacity: 100 - retirementDate: '2026-09-30' + capacity: modelCapacity + retirementDate: '2027-09-21' } { name: 'gpt-5.2' publisher: 'OpenAI' version: '2025-12-11' sku: 'GlobalStandard' - capacity: 100 - retirementDate: '2027-02-05' + capacity: modelCapacity + retirementDate: '2027-06-08' } { name: 'text-embedding-3-large' publisher: 'OpenAI' version: '1' sku: 'GlobalStandard' - capacity: 100 - retirementDate: '2027-04-14' + capacity: modelCapacity + retirementDate: '2028-02-09' } { name: 'Mistral-Large-3' publisher: 'Mistral AI' version: '1' sku: 'GlobalStandard' - capacity: 100 - retirementDate: '2099-12-30' + capacity: modelCapacity + retirementDate: '2099-12-31' } { name: 'Phi-4' @@ -259,7 +270,33 @@ var aiFoundryModels = [ version: '7' sku: 'GlobalStandard' capacity: 1 - retirementDate: '2099-10-14' + retirementDate: '2099-12-31' + } +] + +// ===== Phase 4: LLM Backend Config (consumed by Notebook 1 / Notebook 8) ===== +// Shape must match the llmBackendConfig contract documented in +// challenges/bicep/infra/llm-backend-onboarding/main.bicep (lines 30-68). +// Notebook 1 requires backendId / backendType / endpoint / supportedModels[]. +var llmSupportedModels = [for model in aiFoundryModels: { + name: model.name + sku: model.sku + capacity: model.capacity + modelFormat: model.publisher + modelVersion: model.version + retirementDate: model.retirementDate +}] + +var llmBackendConfig = [ + { + backendId: hubFoundryAccountName + backendType: 'ai-foundry' + endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' + authScheme: 'managedIdentity' + authType: 'managed-identity' + priority: 1 + weight: 100 + supportedModels: llmSupportedModels } ] @@ -1224,44 +1261,11 @@ output PII_ANALYTICS_SUBSCRIPTION_KEY string = listSecrets(subscriptionHrPiiAnal output PII_ANALYTICS_SUBSCRIPTION_ID string = subscriptionHrPiiAnalytics.name // Phase 4: LLM Backend Config (JSON array for Notebook 1 dynamic discovery) -output LLM_BACKEND_CONFIG string = base64(string([ - { - name: 'gpt-4.1' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'gpt-4.1' ] - } - { - name: 'gpt-5.4-mini' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'gpt-5.4-mini' ] - } - { - name: 'gpt-5.2' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'gpt-5.2' ] - } - { - name: 'text-embedding-3-large' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'text-embedding-3-large' ] - } - { - name: 'Mistral-Large-3' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'Mistral-Large-3' ] - } - { - name: 'Phi-4' - publisher: 'Azure OpenAI' - endpoint: 'https://${hubFoundryAccountName}.openai.azure.com' - models: [ 'Phi-4' ] - } -])) +// Emitted as raw JSON (NOT base64) because the notebooks call json.loads() directly. +output LLM_BACKEND_CONFIG string = string(llmBackendConfig) + +// Foundry account hosting agents (Notebook 8 override: A2A_FOUNDRY_ACCOUNT_NAME) +output A2A_FOUNDRY_ACCOUNT_NAME string = spokeFoundryAccountName output COSMOS_ENDPOINT string = cosmosAccount.properties.documentEndpoint output COSMOS_DATABASE string = cosmosDatabaseName diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.json b/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.json index 0e483cf71..9068efbc1 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.json +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/infra/resources.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.41.2.15936", - "templateHash": "17782287074782196978" + "templateHash": "9296888080003196804" } }, "parameters": { @@ -29,9 +29,32 @@ "metadata": { "description": "Tags to apply to all resources." } + }, + "modelCapacity": { + "type": "int", + "defaultValue": 20, + "minValue": 1, + "maxValue": 500, + "metadata": { + "description": "TPM capacity (thousands of tokens/min) for each chat + embedding model\ndeployment. Sized so that a full subscription of labs fits inside the default\nCognitive Services GlobalStandard quota: the platform packs `labsPerSubscription`\n(8) labs into one subscription and region, so the per-region cost of this lab is\n`modelCapacity x 5 x 8`. At the default of 20 that is 800 of the typical 1000-unit\nper-model quota, leaving headroom for other labs sharing the subscription. Raise it\nonly after confirming quota with `az cognitiveservices usage list -l `." + } } }, "variables": { + "copy": [ + { + "name": "llmSupportedModels", + "count": "[length(variables('aiFoundryModels'))]", + "input": { + "name": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].name]", + "sku": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].sku]", + "capacity": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].capacity]", + "modelFormat": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].publisher]", + "modelVersion": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].version]", + "retirementDate": "[variables('aiFoundryModels')[copyIndex('llmSupportedModels')].retirementDate]" + } + } + ], "hubFoundryAccountName": "[format('aif-hub-{0}', parameters('resourceToken'))]", "hubFoundryProjectName": "citadel-hub-project", "apimName": "[format('apim-citadel-{0}', parameters('resourceToken'))]", @@ -79,40 +102,40 @@ "publisher": "OpenAI", "version": "2025-04-14", "sku": "GlobalStandard", - "capacity": 100, - "retirementDate": "2026-10-14" + "capacity": "[parameters('modelCapacity')]", + "retirementDate": "2027-04-14" }, { "name": "gpt-5.4-mini", "publisher": "OpenAI", "version": "2026-03-17", "sku": "GlobalStandard", - "capacity": 100, - "retirementDate": "2026-09-30" + "capacity": "[parameters('modelCapacity')]", + "retirementDate": "2027-09-21" }, { "name": "gpt-5.2", "publisher": "OpenAI", "version": "2025-12-11", "sku": "GlobalStandard", - "capacity": 100, - "retirementDate": "2027-02-05" + "capacity": "[parameters('modelCapacity')]", + "retirementDate": "2027-06-08" }, { "name": "text-embedding-3-large", "publisher": "OpenAI", "version": "1", "sku": "GlobalStandard", - "capacity": 100, - "retirementDate": "2027-04-14" + "capacity": "[parameters('modelCapacity')]", + "retirementDate": "2028-02-09" }, { "name": "Mistral-Large-3", "publisher": "Mistral AI", "version": "1", "sku": "GlobalStandard", - "capacity": 100, - "retirementDate": "2099-12-30" + "capacity": "[parameters('modelCapacity')]", + "retirementDate": "2099-12-31" }, { "name": "Phi-4", @@ -120,7 +143,19 @@ "version": "7", "sku": "GlobalStandard", "capacity": 1, - "retirementDate": "2099-10-14" + "retirementDate": "2099-12-31" + } + ], + "llmBackendConfig": [ + { + "backendId": "[variables('hubFoundryAccountName')]", + "backendType": "ai-foundry", + "endpoint": "[format('https://{0}.openai.azure.com', variables('hubFoundryAccountName'))]", + "authScheme": "managedIdentity", + "authType": "managed-identity", + "priority": 1, + "weight": 100, + "supportedModels": "[variables('llmSupportedModels')]" } ] }, @@ -1284,7 +1319,11 @@ }, "LLM_BACKEND_CONFIG": { "type": "string", - "value": "[base64(string(createArray(createObject('name', 'gpt-4.1', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('gpt-4.1')), createObject('name', 'gpt-5.4-mini', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('gpt-5.4-mini')), createObject('name', 'gpt-5.2', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('gpt-5.2')), createObject('name', 'text-embedding-3-large', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('text-embedding-3-large')), createObject('name', 'Mistral-Large-3', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('Mistral-Large-3')), createObject('name', 'Phi-4', 'publisher', 'Azure OpenAI', 'endpoint', format('https://{0}.openai.azure.com', variables('hubFoundryAccountName')), 'models', createArray('Phi-4')))))]" + "value": "[string(variables('llmBackendConfig'))]" + }, + "A2A_FOUNDRY_ACCOUNT_NAME": { + "type": "string", + "value": "[variables('spokeFoundryAccountName')]" }, "COSMOS_ENDPOINT": { "type": "string", diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/run-local.ps1 b/03-Azure/01-04-AI/06_AI_Governance/labautomation/run-local.ps1 index 55f597191..f0b03aa0b 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/run-local.ps1 +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/run-local.ps1 @@ -41,6 +41,23 @@ function Get-MhhStableHash { return $hex.Substring(0, $Length) } +# Platform-provided helper shim: classifies deployment failures as retryable-in-another-region +# (capacity/quota/SKU availability) or not. Without this shim a local run treats EVERY failure as +# retryable and burns all three regions on errors that are region-independent. +function Test-MhhDeploymentFailureRetryable { + param([Parameter(Mandatory = $true)]$ErrorRecord) + $msg = "$ErrorRecord" + $retryablePatterns = @( + 'SkuNotAvailable', 'QuotaExceeded', 'InsufficientQuota', 'capacity', + 'not available in .* region', 'LocationNotAvailable', 'ServiceUnavailable', + 'SubscriptionDoesNotHaveServer', 'NotAvailableForSubscription' + ) + foreach ($p in $retryablePatterns) { + if ($msg -match $p) { return $true } + } + return $false +} + # Pre-create the resource group (the platform does this for 'resourcegroup' type). if (-not (Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { Write-Host "[INFO] Creating resource group '$ResourceGroupName' in '$Location'..." diff --git a/03-Azure/01-04-AI/06_AI_Governance/labautomation/setup-notebook-env.ps1 b/03-Azure/01-04-AI/06_AI_Governance/labautomation/setup-notebook-env.ps1 index 40df35fbc..3a9a10775 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/labautomation/setup-notebook-env.ps1 +++ b/03-Azure/01-04-AI/06_AI_Governance/labautomation/setup-notebook-env.ps1 @@ -37,7 +37,7 @@ .PARAMETER SubscriptionId Value of the HackboxCredential 'SubscriptionId'. .PARAMETER LlmBackendConfig - Value of the HackboxCredential 'LlmBackendConfig' (base64 JSON). + Value of the HackboxCredential 'LlmBackendConfig' (raw JSON). .PARAMETER SpokeKeyVaultName Value of the HackboxCredential 'SpokeKeyVaultName'. .PARAMETER SpokeAiFoundryAccountName diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-01/solution-01.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-01/solution-01.md index 222733ad2..55c9939ea 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-01/solution-01.md @@ -12,7 +12,8 @@ Duration: 35 min This challenge is driven by [`challenges/workshop/1. llm-backend-onboarding-runner.ipynb`](../../challenges/workshop/1.%20llm-backend-onboarding-runner.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-02/solution-02.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-02/solution-02.md index 5daaaf375..300a52a46 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-02/solution-02.md @@ -12,7 +12,8 @@ Duration: 25 min This challenge is driven by [`challenges/workshop/2. citadel-universal-llm-api-all-models-tests.ipynb`](../../challenges/workshop/2.%20citadel-universal-llm-api-all-models-tests.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-03/solution-03.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-03/solution-03.md index b70e532ef..efbc1165d 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-03/solution-03.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-03/solution-03.md @@ -12,7 +12,8 @@ Duration: 35 min This challenge is driven by [`challenges/workshop/3. citadel-access-contracts-tests.ipynb`](../../challenges/workshop/3.%20citadel-access-contracts-tests.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-04/solution-04.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-04/solution-04.md index 0828c78f8..20a6a2ecd 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-04/solution-04.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-04/solution-04.md @@ -12,7 +12,8 @@ Duration: 30 min This challenge is driven by [`challenges/workshop/4. citadel-agent-frameworks-tests.ipynb`](../../challenges/workshop/4.%20citadel-agent-frameworks-tests.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-05/solution-05.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-05/solution-05.md index 442c1d7b2..f46165405 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-05/solution-05.md @@ -12,7 +12,8 @@ Duration: 35 min This challenge is driven by [`challenges/workshop/5. citadel-pii-processing-tests.ipynb`](../../challenges/workshop/5.%20citadel-pii-processing-tests.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-06/solution-06.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-06/solution-06.md index 2c924bd14..161878783 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-06/solution-06.md @@ -12,7 +12,8 @@ Duration: 35 min This challenge is driven by [`challenges/workshop/6. citadel-unified-ai-api-tests.ipynb`](../../challenges/workshop/6.%20citadel-unified-ai-api-tests.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-07/solution-07.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-07/solution-07.md index 178198b2b..084070c94 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-07/solution-07.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-07/solution-07.md @@ -16,7 +16,8 @@ Duration: 40 min This challenge is driven by [`challenges/workshop/7. citadel-hosted-agent-with-agt.ipynb`](../../challenges/workshop/7.%20citadel-hosted-agent-with-agt.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-08/solution-08.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-08/solution-08.md index 5239e3d17..3562f9998 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-08/solution-08.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-08/solution-08.md @@ -16,7 +16,8 @@ Duration: 35 min This challenge is driven by [`challenges/workshop/8. publish-and-use-a2a-endpoint.ipynb`](../../challenges/workshop/8.%20publish-and-use-a2a-endpoint.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell. diff --git a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-09/solution-09.md b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-09/solution-09.md index 0804aeb4f..d81f27df9 100644 --- a/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-09/solution-09.md +++ b/03-Azure/01-04-AI/06_AI_Governance/walkthrough/challenge-09/solution-09.md @@ -16,7 +16,8 @@ Duration: variable This challenge is driven by [`challenges/workshop/9. publish-and-use-hr-mcp-via-apim.ipynb`](../../challenges/workshop/9.%20publish-and-use-hr-mcp-via-apim.ipynb). -The notebook is the **unmodified upstream Citadel workshop notebook** and is the +The notebook is the upstream Citadel workshop notebook (unchanged apart from its +setup cell, which now also reads configuration from the environment) and is the authoritative solution — it contains the working code, the expected output, and its own `📊 Results Summary` cell.