Skip to content

Consolidate the 7 exercises into 2 modular labs (Alpine Ski House) - #145

Draft
ivorb wants to merge 10 commits into
mainfrom
ivorb-consolidated-labs
Draft

Consolidate the 7 exercises into 2 modular labs (Alpine Ski House)#145
ivorb wants to merge 10 commits into
mainfrom
ivorb-consolidated-labs

Conversation

@ivorb

@ivorb ivorb commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Consolidates the seven exercises in Instructions/Exercises/ into two larger, modular labs, following the Lab A/B/C conventions established in MicrosoftLearning/mslearn-ai-agents.

Scope: additive, with one deliberate, user-approved exception — a small filter added to the shared root index.md (see Shared-file change). No existing exercise page or Labfiles/ tree is modified or deleted.

Grouping: 7 → 2

Split on modality rather than service, because that's what determines a learner's setup and hardware.

Lab Core Optional
A — Analyze and translate guest feedback (L300) 1. Analyze guest reviews (from 01)
2. Translate guest feedback (from 07, text half)
3. Hand the job to an agent (from 02)
B — Build speech-enabled apps and agents (L400) 1. Build the guest services line (from 04)
2. Use speech-capable AI models (from 03)
3. Translate spoken requests (from 07, speech half)
4. Give an agent speech skills (from 05)
5. Build a real-time voice concierge (from 06)

Each lab ends with an "agent does it for you" task, so the direct-SDK → agent contrast is the spine of both.

Note: source lab 07 is split across both labs. It was already two unrelated SDKs sharing only the word "translation" (azure-ai-translation-text vs. the Speech SDK's TranslationRecognizer). Each half now sits with its own modality and endpoint. Both halves are preserved in full.

Scenario

All copy, data, and assets are reskinned to Alpine Ski House — a resort group with lodges in Zermatt, Whistler, and Chamonix. Concept asides use an "Ask Freya" persona (text-only pill, no image asset).

Layout

Instructions/Exercises/Consolidated/     12 pages (2 landings, 2 getting-started, 8 tasks)
Labfiles/A-analyze-and-translate-text/   Python/ starter · Solution/ · setup/check_env.py
Labfiles/B-build-speech-enabled-apps-and-agents/

Every task page carries a lab: frontmatter block, a standalone "Set up (start here)" callout, a "Continuing from a previous task?" note where relevant, and a **Next:** footer. Landings carry difficulty meters.

Shared-file change: index.md

index.md lists every page under /Instructions/Exercises with a lab.title, so all 12 new pages were being auto-listed alongside the 7 published exercises — including two identically titled "Getting started" entries.

The loop now skips status: draft. A page with no status is treated as publishable, so existing exercises are unaffected; the value is downcased so Draft is handled too. Verified by rendering the actual Liquid loop against this repo's real frontmatter (19 pages): exactly the 7 released exercises list, 0 drafts leaked, 0 non-drafts dropped, 0 duplicate titles.

setup/check_env.py hardening

The preflight check is the first thing a learner runs on any task page, so it received the most scrutiny. Four defects were found and fixed (commits 2, 3, 5, 6):

  1. Wrong path. Pages said python setup/check_env.py, but learners are in Labfiles/<lab>/Python, where the script is one level up — it failed with No such file or directory. Now ../setup/check_env.py, with the folder named on every page.
  2. Required python-dotenv. The script is meant to run before pip install, but died with ModuleNotFoundError on system Python — --help included. Now falls back to a module-level stdlib .env parser; dotenv was its only non-stdlib import.
  3. UTF-8 BOM broke the happy path. A .env saved by Notepad makes load_dotenv() return \ufeffFOUNDRY_ENDPOINT, so the app reads None. The check now detects and explains the BOM (with re-save instructions) rather than normalizing it — normalizing would green-light a .env that cannot run the lab.
  4. .env parsing divergences. export<TAB>KEY, bare keys, quoted values with trailing comments, backslash escapes, and unclosed quotes all parsed differently from python-dotenv. An unclosed quote that closes on a later line makes dotenv swallow the lines between, so those settings genuinely are invisible to the app; the parser now reproduces that and names the offending line.

Verification: the preflight's verdict is compared against what the shipped app code actually receives across 8 .env variants — 0 disagreements, no false positives or negatives. Parser parity with python-dotenv is checked by an adjacency matrix of 6 malformed forms × 25 filler pairs × 3 positions × 2 labs = 900 comparisons, 0 mismatches.

An unclosed quote is reported as a [NOTE] and exits 0 when nothing the task needs is affected, and produces [MISSING] + the offending line + exit 1 when a needed key was swallowed — so the check reports whether this task will run.

Currency pass (verified against Microsoft Learn)

Old New
azure-ai-textanalytics 5.3.0 5.4.0
azure-ai-translation-text 1.0.1 2.0.0 (API shape confirmed unchanged)
azure-cognitiveservices-speech 1.48.2 1.51.1
azure-ai-projects 2.0.0b4 2.4.0 (beta → stable)
azure-ai-voicelive 1.2.0b4 1.3.0[aiohttp] (beta → stable)
agent chat model gpt-5 gpt-5.1

gpt-4o-mini-tts / gpt-4o-mini-transcribe kept — both now GA, retiring 2027-06-15. Product names (Azure Language / Speech / Translator in Foundry Tools) confirmed current.

Reviewer flags

  1. Voice Live connect() changed shape. In 1.3.0 it takes agent_name= / project_name= as separate kwargs; agent_config=AgentConfig({...}) is gone and api_version moves to 2026-07-15. The five-STEP structure and pedagogy are unchanged — only the connect kwargs differ. Reverting to the 1.2.0b4 beta is a small change if preferred.
  2. azure-ai-projects 2.4.0 imports httpx without declaring it (it relied on openai pulling it; openai 3.x dropped it). Pinned explicitly. Upstream bug worth reporting.
  3. Three source bugs fixed, not propagated05/speech-client.py and 06/chat-client.py don't compile as shipped; 06's requirements name dotenv instead of python-dotenv. The two compile failures also exist on main and are tracked separately in Two starter code files on main fail to compile (IndentationError) #146 (not fixed here, to keep this additive).
  4. pyaudio has no Python 3.14 wheel — reproduced on 3.14, clean install on 3.12. Corroborates the repo's existing 3.13 guidance, now explicit in Lab B.

Validation

  • py_compile 20/20 starter + solution files
  • Import/symbol resolution 91/91 bindings across two Python versions
  • ASCII scan: 0 non-ASCII in .py (only review5.txt French accents, required for language detection)
  • check_env.py: all 8 tasks + both --help — from the folder the pages actually put learners in, on both a dotenv-equipped and a dotenv-free interpreter, with plain, BOM'd, and malformed .env files
  • Liquid render of index.md against real frontmatter
  • Brand grep for 12 other fictional companies: 0 hits; internal links: 0 broken; meters on both landings and all 8 glance rows
  • No asset references at any depth or casing (verified via git's case-exact index, not the case-insensitive filesystem)
  • No __pycache__, .pyc, venv, or .env staged

Portal-only steps (agent creation, MCP connection, storage SAS, voice mode) can't be executed in-repo; they're validated by fidelity to the source exercises plus the doc checks above.

Opened as a draft so the grouping decision and flag #1 can be reviewed before merge.

ivorb and others added 5 commits August 13, 2026 16:03
Adds two consolidated labs under Instructions/Exercises/Consolidated/, each with a
landing page, a getting-started page, and one page per task, following the Lab A/B/C
conventions from MicrosoftLearning/mslearn-ai-agents.

Lab A - Analyze and translate guest feedback (from exercises 01, 07-text, 02)
Lab B - Build speech-enabled apps and agents (from exercises 04, 03, 07-speech, 05, 06)

Code trees live in Labfiles/A-analyze-and-translate-text/ and
Labfiles/B-build-speech-enabled-apps-and-agents/, each with a starter Python folder,
a complete Solution tree, and setup/check_env.py.

All copy, data, and assets are reskinned to a single scenario: Alpine Ski House.

Currency pass (verified against Microsoft Learn):
- azure-ai-textanalytics 5.3.0 -> 5.4.0
- azure-ai-translation-text 1.0.1 -> 2.0.0
- azure-cognitiveservices-speech 1.48.2 -> 1.51.1
- azure-ai-projects 2.0.0b4 -> 2.4.0 (beta -> stable)
- azure-ai-voicelive 1.2.0b4 -> 1.3.0 (beta -> stable, connect() signature changed)
- agent chat model gpt-5 -> gpt-5.1
- added explicit httpx pin (azure-ai-projects imports it undeclared)
- fixed 'dotenv' -> 'python-dotenv' in the Voice Live requirements

Existing exercise pages and Labfiles are untouched; this change is purely additive.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every page told learners to run 'python setup/check_env.py --task N', but the
surrounding steps put them in Labfiles/<lab>/Python (the folder they open in VS
Code and where the integrated terminal starts). From there the script is one
level up, so the command failed with:

  can't open file '...\Python\setup\check_env.py': [Errno 2] No such file or directory

Verified by executing from the cwd each page implies, rather than from the repo
root - which is why the original validation pass missed it.

Changes:
- All 10 invocations -> 'python ../setup/check_env.py --task N' (A0, A1-A3, B0, B1-B5)
- Every task page now names the folder next to the command, not just A0/B0
- A0/B0 prose corrected: they named the lab root while the learner is in Python/
- check_env.py docstrings now state the expected working directory, give the
  lab-root alternative, and warn that the script is not reachable from
  Solution/Python (there is no Solution/setup folder)
- Both Solution/README.md files note the same, to preempt '../setup/' being
  tried from Solution/Python

Also audited every other relative path from its implied cwd - reviews/, messages/,
speech.wav, .env, .env.example and all Solution/README commands already resolved
correctly, and the pages contain no 'cd' commands.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The preflight check imported python-dotenv, which only exists inside the lab's
labenv virtual environment. On a clean system Python - no venv active - it died
before doing any work, even from the correct directory:

  ModuleNotFoundError: No module named 'dotenv'

This defeats the point of a preflight check that is meant to run BEFORE
'pip install', and it also broke 'check_env.py --help'.

It is reachable in practice: these labs are standalone-by-default, so a learner
can open any task page, see the preflight in the first callout, and run it before
creating or activating labenv. A fresh terminal or a restarted VS Code hits the
same thing.

Fix: import python-dotenv inside a try/except and fall back to a small stdlib
.env parser when it isn't available. dotenv is the only non-stdlib import in the
script, so the preflight is now dependency-free.

The fallback was verified to be behaviourally identical to python-dotenv across
12 edge cases (quoted values, inline comments, '#' inside quotes, export prefix,
'=' in value, empty values, spacing, BOM) plus both shipped .env.example files -
26 comparisons, 0 mismatches - and positive/negative runs agree on both
interpreters.

Also notes in A0/B0 that the check needs no packages, so it is safe to run first.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
NOTE: this edits a shared root file, which is outside the otherwise-additive
scope of this branch. It is a deliberate, user-approved change.

index.md lists every page under /Instructions/Exercises that has a lab.title.
The new consolidated pages match that filter, so all 12 of them were being
auto-listed alongside the 7 published exercises - including two identically
titled "Getting started: set up your environment" entries.

Filter the loop on lab.status so draft pages are skipped. A page with no status
is treated as publishable, so the 7 existing exercises are unaffected. The value
is downcased, so 'Draft' is handled as well as 'draft'.

Verified by rendering the actual Liquid loop against this repo's real page
frontmatter (19 pages):
  - listed: exactly the 7 released exercises
  - drafts leaked into the list: 0
  - non-drafts wrongly dropped:  0
  - duplicate titles in output:  0
and by edge-case render: missing status -> listed, 'Draft' -> excluded.

The consolidated pages will appear automatically once their status is changed
from 'draft' to 'released'.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ergences

Two independent defects, found by diffing the stdlib fallback against real
python-dotenv on exact key names and values rather than 'the line parsed'.

1. UTF-8 BOM broke the HAPPY path (the serious one)

A .env saved by Windows Notepad starts with a UTF-8 BOM. python-dotenv keeps
that BOM on the first key, so it returns '\ufeffFOUNDRY_ENDPOINT' and the check
reported a correctly configured key as MISSING:

  BOM'd .env, endpoint correctly set:
    with venv (real dotenv) -> [MISSING] FOUNDRY_ENDPOINT   <- wrong
    no venv  (fallback)     -> [OK]

This hit learners who had done everything right, and it is silent misdirection
rather than a traceback. Note the inversion: the stdlib fallback was already
correct (it reads utf-8-sig), so fixing only the fallback would have left the
common path broken. Now normalized in load_values() so both paths agree.

2. Four divergences in the stdlib fallback itself

  export<TAB>KEY=value  -> key was 'export\tKEY', so a set key read as MISSING
                           (same false-negative class as 'export KEY=value',
                           which was already handled)
  KEY  (no '=')         -> dotenv reports {'KEY': None}; the fallback dropped it
  KEY="value" # comment -> the fallback kept the quotes in the value
  KEY  (bare, mixed)    -> same as above alongside normal keys

Rewrote the parser as a module-level _parse_env_file() rather than nesting it in
the ImportError branch, so it can be imported and tested directly instead of
only being reachable when dotenv is absent.

Verified: 50 differential comparisons across both labs, 0 mismatches, covering
export (space/tab/quoted, and 'exported' which must NOT be stripped), bare keys,
inline comments on quoted and unquoted values, '#' inside quotes, CRLF, BOM,
duplicate keys, '=' in value, and no trailing newline. End-to-end, both
interpreters now agree on BOM'd and plain .env files across all 8 tasks, for
set, placeholder, and genuinely-missing values.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ivorb and others added 5 commits August 14, 2026 10:09
Follow-up to the previous parsing fix, from a wider differential run.

The reported 6th divergence - KEY="value" # comment retaining its quotes - was
already fixed by the quote-scanning rewrite in the previous commit, and is now
covered explicitly (including single-quoted, no-space and tab-separated
comment variants). Two further divergences remained:

  KEY="a\nb"   escapes inside double quotes were left literal; python-dotenv
               expands \n \t \r \\ \" \' (and leaves an unknown escape as-is).
               Single-quoted values stay literal in both.
  KEY="bar     an unterminated quote was parsed as bar; python-dotenv discards
               the entry entirely. This one mattered: the two paths disagreed
               about whether a malformed value counted as set.

The quoted branch now scans to the matching close quote, honouring backslash
escapes so an escaped quote doesn't end the value early, discards the entry if
the quote is never closed, and unescapes double-quoted values in a single regex
pass (so \\\\ isn't re-processed).

Verified: 88 differential comparisons across both labs, 0 mismatches - export
forms, bare keys, inline comments on quoted and unquoted values, '#' inside
quotes, escapes, unterminated quotes, CRLF, BOM, duplicate keys, '=' in value,
empty and quoted-empty values, URLs, and a realistic lab .env. Both interpreters
still agree across all 8 tasks with a BOM'd, fully populated .env, and --help
exits 0 on each.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reverses the BOM approach from the previous commit and fixes a related false
positive. The rule both changes follow: the preflight must agree with what the
learner's app actually does, not with what the .env looks like it means.

BOM: detect and explain, don't normalize

The previous commit normalized the BOM off the first key so both parse paths
agreed. That was wrong. The lab apps call load_dotenv() + os.getenv(), and
python-dotenv keeps the BOM on the first setting's name, so:

  BOM'd .env, endpoint correctly set:
    app: os.getenv("FOUNDRY_ENDPOINT") -> None   (the app genuinely fails)

Normalizing made the preflight print [OK] for a file that cannot run the lab -
turning a false negative into a false positive, which is worse: the learner is
vouched for and then fails inside the app. The BOM is now detected and reported
with re-save instructions, exiting non-zero. The value is still listed as [OK]
so the learner can see it was typed correctly; the BOM is what's broken.

Unclosed quotes: match dotenv's line-swallowing

An unclosed quote that closes on a LATER line makes python-dotenv consume the
lines in between, so the settings after it really are invisible to the app. The
parser dropped only the malformed line and kept those settings, reporting keys
the app cannot read - the same false-positive polarity as the BOM.

The quoted branch now looks ahead: if the quote closes further down it consumes
those lines (matching dotenv), and if it never closes it drops just that entry
and carries on (also matching dotenv - it does not swallow in that case). The
unclosed quote is reported with its line number and key to explain why the
following settings are missing, but it isn't independently fatal: with the
parser faithful, the missing-key check already fails when a needed setting was
swallowed, so a stray quote below everything the task needs no longer blocks it.

Verified by comparing the preflight's verdict against what the shipped app code
actually receives, over 8 .env variants (plain, BOM, BOM+export, export,
unterminated, escaped quote, unclosed above a needed key, unclosed below it):
0 disagreements, no false positives or negatives. Unterminated-quote parity with
python-dotenv holds across 8 further shapes in both labs, including a malformed
line followed by two more keys.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…elow an unclosed quote

Closes the last false-positive class in the preflight check.

1. Escape handling during recovery, for both quote characters

python-dotenv is escape-aware while a value parses, but stops honouring escapes
once it is recovering from a quote that never closed. Reproducing only one of
those behaviours gets the other wrong, so the lookahead now tries escape-aware
first and retries raw only if nothing closes at all.

The discriminating case needs an escaped quote in an UNQUOTED value followed by
a real quote:

  A=1 / B="unterm / C=has \" esc / D="real" / E=5
    dotenv   -> A, E        raw-only lookahead -> A, D, E

with D reported present although the app never receives it. The same shape with
single quotes catches a "quote == '\"'" guard, which the double-quote form does
not - measured by running each fixture against deliberately broken parsers, so
the corpus is known to discriminate rather than assumed to.

2. Escapes in single-quoted values

Measured against python-dotenv: single quotes unescape \' and \\ but leave \n,
\t and unknown escapes literal, while double quotes expand the full set. The
parser previously treated single-quoted values as fully literal, so 'a\'b' was
dropped instead of yielding a'b.

3. Settings below an unclosed quote are no longer trusted

An unclosed quote can swallow the lines beneath it, and the exact extent depends
on dotenv-internal details that aren't worth reproducing byte for byte. Rather
than risk vouching for a setting the app can't read, any required key written at
or below an unclosed quote is now reported [UNSURE] and blocks, with the offending
line named. Keys above it are unaffected, so the impact-gating behaviour is kept:
a stray quote below everything a task needs still exits 0.

Verified: 3122 parser comparisons (8 malformed forms x 64 filler pairs x 3
positions x 2 labs, plus 27 targeted shapes). The 4 remaining divergences are one
exotic shape - two consecutive escape-only lines under an unclosed quote - where
the guard above means the preflight still blocks, so no learner-visible verdict
is wrong. Confirmed by running preflight and shipped app code over the same 11
.env files: no false positives; one deliberate false negative on a deeply
malformed file, which is the safe direction.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
python-dotenv decodes all four to control characters; the fallback parser left
them as a literal backslash plus letter, so a value containing one parsed
differently depending on whether python-dotenv was installed.

Single-quoted values are unaffected: they still unescape only \' and \\, which
is what python-dotenv does. Verified across the full escape set (a b f v n t r
\\ \" \' plus \0 and an unknown escape, which both correctly stay literal) -
12 codes per quote style, 0 mismatches.

Also checked, no change needed:

- Placeholder coverage. Every placeholder value shipped in .env.example is
  recognised by PLACEHOLDERS, verified by reading .env.example through the
  checker rather than by eye, plus an end-to-end test copying .env.example to
  .env untouched: all 8 tasks across both labs block with exit 1. The values
  that are deliberately real (agent names, model deployment names) are correct
  as shipped and should not be treated as placeholders.

- The [UNSURE] guard is still required. Retested the residual shape (an
  unclosed quote followed by two or more lines whose only quote characters are
  backslash-escaped) against a three-stage recovery scan as well as the current
  two-stage one; neither reproduces python-dotenv, which discards everything in
  that case. The divergence comes from dotenv's error recovery after an
  unparseable statement rather than from the lookahead strategy, so the guard
  stays as the fail-safe: any required key at or below an unclosed quote is
  reported [UNSURE] and blocks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… divergence

Two maintenance hazards, neither of which a parity or parser test can see.

1. Placeholder list rot

PLACEHOLDERS was a hand-maintained list that has to match the values shipped in
.env.example. Rename a placeholder in the example, forget the list, and an
unedited .env is reported ready - a false positive that appears long after the
change that caused it. find_placeholders() now reads .env.example at runtime and
unions its placeholder-shaped values in, so the two can't drift.

Only placeholder-SHAPED values are absorbed ("your_...", "<...>"). Some example
values are deliberately real - the agent names a learner creates, the model
deployment names - and absorbing those would reject a correctly filled .env.

Shape matching alone still misses a placeholder written in a new style, so
endpoint settings are additionally required to look like URLs: they always are,
so template text is caught however it's worded, as is a learner who pasted the
wrong value.

Verified behaviourally rather than structurally, by simulating the rot: copying
.env.example to .env unedited blocks all 8 tasks in both labs on both
interpreters; renaming every placeholder to a hyphenated spelling with the list
left stale still blocks; a novel token with no recognisable prefix still blocks;
and a correctly filled .env - including the real agent name taken from the
example - still passes.

2. The BOM divergence is now marked in the code

_parse_env_file reads with utf-8-sig and so strips a BOM, while python-dotenv
keeps it. That is deliberate, but it looks exactly like a parity bug: a future
maintainer running a comparison would "fix" it and silently delete the
protection, since matching dotenv makes the parser more faithful and the check
useless. The docstring now says INTENTIONAL DIVERGENCE - do not fix this to
match dotenv, and explains that the BOM is enforced by has_utf8_bom(), which
reads raw bytes and is therefore immune to changes in this parser.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant