{"kind":"Skill","metadata":{"namespace":"community","name":"azure-architecture-autopilot","version":"0.1.0"},"spec":{"description":"\u003e","files":{".gitignore":"# Temporary files\n*.pyc\n__pycache__/\n*.egg-info/\n.DS_Store\nThumbs.db\n\n# Test/eval outputs (not included in repository)\nevals/outputs/\nworkspace/\n\n# Generated artifacts (not included in repository)\noutput/\n*.png\n*.svg\n!assets/*.png\n!assets/*.svg\n\n# Sample diagrams (contain hardcoded example values — prevent model context contamination)\nsample_*.html\n\n# Environment configuration\n.env\n*.local\n\n# Package files (build artifacts)\n*.skill\n\n# Development-only folder (not included in public distribution)\ndev/\n","SKILL.md":"---\nname: azure-architecture-autopilot\ndescription: \u003e\n  Design Azure infrastructure using natural language, or analyze existing Azure resources\n  to auto-generate architecture diagrams, refine them through conversation, and deploy with Bicep.\n\n  When to use this skill:\n  - \"Create X on Azure\", \"Set up a RAG architecture\" (new design)\n  - \"Analyze my current Azure infrastructure\", \"Draw a diagram for rg-xxx\" (existing analysis)\n  - \"Foundry is slow\", \"I want to reduce costs\", \"Strengthen security\" (natural language modification)\n  - Azure resource deployment, Bicep template generation, IaC code generation\n  - Microsoft Foundry, AI Search, OpenAI, Fabric, ADLS Gen2, Databricks, and all Azure services\n---\n\n# Azure Architecture Builder\n\nA pipeline that designs Azure infrastructure using natural language, or analyzes existing resources to visualize architecture and proceed through modification and deployment.\n\nThe diagram engine is **embedded within the skill** (`scripts/` folder).\nNo `pip install` needed — it directly uses the bundled Python scripts\nto generate interactive HTML diagrams with 605+ official Azure icons.\nReady to use immediately without network access or package installation.\n\n## Automatic User Language Detection\n\n**🚨 Detect the language of the user's first message and provide all subsequent responses in that language. This is the highest-priority principle.**\n\n- If the user writes in Korean → respond in Korean\n- If the user writes in English → **respond in English** (ask_user, progress updates, reports, Bicep comments — all in English)\n- The instructions and examples in this document are written in English, and **all user-facing output must match the user's language**\n\n**⚠️ Do not copy examples from this document verbatim to the user.**\nUse only the structure as reference, and adapt text to the user's language.\n\n## Tool Usage Guide (GHCP Environment)\n\n| Feature | Tool Name | Notes |\n|---------|-----------|-------|\n| Fetch URL content | `web_fetch` | For MS Docs lookups, etc. |\n| Web search | `web_search` | URL discovery |\n| Ask user | `ask_user` | `choices` must be a string array |\n| Sub-agents | `task` | explore/task/general-purpose |\n| Shell command execution | `powershell` | Windows PowerShell |\n\n\u003e All sub-agents (explore/task/general-purpose) cannot use `web_fetch` or `web_search`.\n\u003e Fact-checking that requires MS Docs lookups must be performed **directly by the main agent**.\n\n## External Tool Path Discovery\n\n`az`, `python`, `bicep`, etc. are often not on PATH.\n**Discover once before starting a Phase and cache the result. Do not re-discover every time.**\n\n\u003e **⚠️ Do not use `Get-Command python`** — risk of Windows Store alias.\n\u003e Direct filesystem discovery (`$env:LOCALAPPDATA\\Programs\\Python`) takes priority.\n\naz CLI path:\n```powershell\n$azCmd = $null\nif (Get-Command az -ErrorAction SilentlyContinue) { $azCmd = 'az' }\nif (-not $azCmd) {\n  $azExe = Get-ChildItem -Path \"$env:ProgramFiles\\Microsoft SDKs\\Azure\\CLI2\\wbin\", \"$env:LOCALAPPDATA\\Programs\\Azure CLI\\wbin\" -Filter \"az.cmd\" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName\n  if ($azExe) { $azCmd = $azExe }\n}\n```\n\nPython path + embedded diagram engine: refer to the diagram generation section in `references/phase1-advisor.md`.\n\n## Progress Updates Required\n\nUse blockquote + emoji + bold format:\n```markdown\n\u003e **⏳ [Action]** — [Reason]\n\u003e **✅ [Complete]** — [Result]\n\u003e **⚠️ [Warning]** — [Details]\n\u003e **❌ [Failed]** — [Cause]\n```\n\n## Parallel Preload Principle\n\nWhile waiting for user input via `ask_user`, preload information needed for the next step in parallel.\n\n| ask_user Question | Preload Simultaneously |\n|---|---|\n| Project name / scan scope | Reference files, MS Docs, Python path discovery, **diagram module path verification** |\n| Model/SKU selection | MS Docs for next question choices |\n| Architecture confirmation | `az account show/list`, `az group list` |\n| Subscription selection | `az group list` |\n\n---\n\n## Path Branching — Automatically Determined by User Request\n\n### Path A: New Design (New Build)\n\n**Trigger**: \"create\", \"set up\", \"deploy\", \"build\", etc.\n```\nPhase 1 (references/phase1-advisor.md) — Interactive architecture design + diagram\n    ↓\nPhase 2 (references/bicep-generator.md) — Bicep code generation\n    ↓\nPhase 3 (references/bicep-reviewer.md) — Code review + compilation verification\n    ↓\nPhase 4 (references/phase4-deployer.md) — validate → what-if → deploy\n```\n\n### Path B: Existing Analysis + Modification (Analyze \u0026 Modify)\n\n**Trigger**: \"analyze\", \"current resources\", \"scan\", \"draw a diagram\", \"show my infrastructure\", etc.\n```\nPhase 0 (references/phase0-scanner.md) — Existing resource scan + diagram\n    ↓\nModification conversation — \"What would you like to change here?\" (natural language modification request → follow-up questions)\n    ↓\nPhase 1 (references/phase1-advisor.md) — Confirm modifications + update diagram\n    ↓\nPhase 2~4 — Same as above\n```\n\n### When Path Determination Is Ambiguous\n\nAsk the user directly:\n```\nask_user({\n  question: \"What would you like to do?\",\n  choices: [\n    \"Design a new Azure architecture (Recommended)\",\n    \"Analyze + modify existing Azure resources\"\n  ]\n})\n```\n\n---\n\n## Phase Transition Rules\n\n- Each Phase reads and follows the instructions in its corresponding `references/*.md` file\n- When transitioning between Phases, always inform the user about the next step\n- Do not skip Phases (especially the what-if between Phase 3 → Phase 4)\n- **🚨 Required condition for Phase 1 → Phase 2 transition**: `01_arch_diagram_draft.html` must have been generated using the embedded diagram engine and shown to the user. **Do not proceed to Bicep generation without a diagram.** Completing spec collection alone does not mean Phase 1 is done — Phase 1 includes diagram generation + user confirmation.\n- Modification request after deployment → return to Phase 1, not Phase 0 (Delta Confirmation Rule)\n\n## Service Coverage \u0026 Fallback\n\n### Optimized Services\nMicrosoft Foundry, Azure OpenAI, AI Search, ADLS Gen2, Key Vault, Microsoft Fabric, Azure Data Factory, VNet/Private Endpoint, AML/AI Hub\n\n### Other Azure Services\nAll supported — MS Docs are automatically consulted to generate at the same quality standard.\n**Do not send messages that cause user anxiety such as \"out of scope\" or \"best-effort\".**\n\n### Stable vs Dynamic Information Handling\n\n| Category | Handling Method | Examples |\n|----------|----------------|---------|\n| **Stable** | Reference files first | `isHnsEnabled: true`, PE triple set |\n| **Dynamic** | **Always fetch MS Docs** | API version, model availability, SKU, region |\n\n## Quick Reference\n\n| File | Role |\n|------|------|\n| `references/phase0-scanner.md` | Existing resource scan + relationship inference + diagram |\n| `references/phase1-advisor.md` | Interactive architecture design + fact checking |\n| `references/bicep-generator.md` | Bicep code generation rules |\n| `references/bicep-reviewer.md` | Code review checklist |\n| `references/phase4-deployer.md` | validate → what-if → deploy |\n| `references/service-gotchas.md` | Required properties, PE mappings |\n| `references/azure-dynamic-sources.md` | MS Docs URL registry |\n| `references/azure-common-patterns.md` | PE/security/naming patterns |\n| `references/ai-data.md` | AI/Data service guide |\n","references/ai-data.md":"# Domain Pack: AI/Data (v1)\n\nService configuration guide specialized for Azure AI/Data workloads.\nv1 scope: Foundry, AI Search, ADLS Gen2, Key Vault, Fabric, ADF, VNet/PE.\n\n\u003e Required properties/common mistakes → `service-gotchas.md`\n\u003e Dynamic information (API version, SKU, region) → `azure-dynamic-sources.md`\n\u003e Common patterns (PE, security, naming) → `azure-common-patterns.md`\n\n---\n\n## 1. Microsoft Foundry (CognitiveServices)\n\n### Resource Hierarchy\n\n```\nMicrosoft.CognitiveServices/accounts (kind: 'AIServices')\n├── /projects          — Foundry Project (required for portal access)\n└── /deployments       — Model deployments (GPT-4o, embedding, etc.)\n```\n\n### Bicep Core Structure\n\n```bicep\n// Foundry resource\nresource foundry 'Microsoft.CognitiveServices/accounts@\u003cfetch\u003e' = {\n  name: foundryName\n  location: location\n  kind: 'AIServices'\n  sku: { name: '\u003cconfirm with user\u003e' }               // ← SKU confirmed after MS Docs check in Phase 1\n  identity: { type: 'SystemAssigned' }\n  properties: {\n    customSubDomainName: foundryName  // ← Required, globally unique. Cannot change after creation — must delete and recreate if omitted\n    allowProjectManagement: true\n    publicNetworkAccess: 'Disabled'\n    networkAcls: { defaultAction: 'Deny' }\n  }\n}\n\n// Foundry Project — Must be created as a set with Foundry\nresource project 'Microsoft.CognitiveServices/accounts/projects@\u003cfetch\u003e' = {\n  parent: foundry\n  name: '${foundryName}-project'\n  location: location\n  sku: { name: '\u003csame as parent\u003e' }\n  kind: 'AIServices'\n  identity: { type: 'SystemAssigned' }\n  properties: {}\n}\n\n// Model deployment — At Foundry resource level\nresource deployment 'Microsoft.CognitiveServices/accounts/deployments@\u003cfetch\u003e' = {\n  parent: foundry\n  name: '\u003cmodel-name\u003e'                              // ← Confirmed with user in Phase 1\n  sku: {\n    name: '\u003cdeployment-type\u003e'                        // ← GlobalStandard, Standard, etc. — MS Docs fetch\n    capacity: \u003cconfirm with user\u003e                    // ← Capacity units — verify available range from MS Docs\n  }\n  properties: {\n    model: {\n      format: 'OpenAI'\n      name: '\u003cmodel-name\u003e'                           // ← Must verify availability (fetch)\n      version: '\u003cfetch\u003e'                             // ← Version also fetched\n    }\n  }\n}\n```\n\n\u003e `@\u003cfetch\u003e`: Verify API version from the URLs in `azure-dynamic-sources.md`.\n\u003e Model name/version/deployment type/capacity: All Dynamic — Confirmed with user after MS Docs fetch in Phase 1.\n\n---\n\n## 2. Azure AI Search\n\n### Bicep Core Structure\n\n```bicep\nresource search 'Microsoft.Search/searchServices@\u003cfetch\u003e' = {\n  name: searchName\n  location: location\n  sku: { name: '\u003cconfirm with user\u003e' }\n  identity: { type: 'SystemAssigned' }\n  properties: {\n    hostingMode: 'default'\n    publicNetworkAccess: 'disabled'\n    semanticSearch: '\u003cconfirm with user\u003e'    // disabled | free | standard — verify in MS Docs\n  }\n}\n```\n\n### Design Notes\n\n- PE support: Basic SKU or higher (verify latest constraints in MS Docs)\n- Semantic Ranker: Activated via `semanticSearch` property (`disabled` | `free` | `standard`) — verify per-SKU support in MS Docs\n- Vector search: Supported on paid SKUs (verify in MS Docs)\n- Commonly used together with Foundry for RAG configurations\n\n---\n\n## 3. ADLS Gen2 (Storage Account)\n\n### Bicep Core Structure\n\n```bicep\nresource storage 'Microsoft.Storage/storageAccounts@\u003cfetch\u003e' = {\n  name: storageName        // Lowercase+numbers only, no hyphens\n  location: location\n  kind: 'StorageV2'\n  sku: { name: 'Standard_LRS' }\n  properties: {\n    isHnsEnabled: true                 // ← Never omit this\n    accessTier: 'Hot'\n    allowBlobPublicAccess: false\n    minimumTlsVersion: 'TLS1_2'\n    publicNetworkAccess: 'Disabled'\n    networkAcls: { defaultAction: 'Deny' }\n  }\n}\n\n// Container\nresource container 'Microsoft.Storage/storageAccounts/blobServices/containers@\u003cfetch\u003e' = {\n  name: '${storage.name}/default/raw'\n}\n```\n\n### Design Notes\n\n- `isHnsEnabled` cannot be changed after creation → Resource must be recreated if omitted\n- PE: May need both `blob` and `dfs` PEs depending on use case\n- Common containers: `raw`, `processed`, `curated`\n\n---\n\n## 4. Microsoft Fabric\n\n### Bicep Core Structure\n\n```bicep\nresource fabric 'Microsoft.Fabric/capacities@\u003cfetch\u003e' = {\n  name: fabricName\n  location: location\n  sku: { name: '\u003cconfirm with user\u003e', tier: 'Fabric' }\n  properties: {\n    administration: {\n      members: [ '\u003cadmin-email\u003e' ]    // ← Required, deployment fails without it\n    }\n  }\n}\n```\n\n### Design Notes\n\n- Only Capacity can be provisioned via Bicep\n- Workspace, Lakehouse, Warehouse, etc. must be created manually in the portal\n- Confirm admin email with the user (`ask_user`)\n\n### Required Confirmation Items When Adding in Phase 1\n\nWhen Fabric is added during conversation, the following items must be confirmed via ask_user before updating the diagram:\n\n- [ ] **SKU/Capacity**: F2, F4, F8, ... — Provide choices after fetching available SKUs from MS Docs\n- [ ] **administration.members**: Admin email — Deployment fails without it\n\n\u003e Do not arbitrarily include sub-workloads (OneLake, data pipelines, Warehouse, etc.) that the user did not specify. Only Capacity can be provisioned via Bicep.\n\n---\n\n## 5. Azure Data Factory\n\n### Bicep Core Structure\n\n```bicep\nresource adf 'Microsoft.DataFactory/factories@\u003cfetch\u003e' = {\n  name: adfName\n  location: location\n  identity: { type: 'SystemAssigned' }\n  properties: {\n    publicNetworkAccess: 'Disabled'\n  }\n}\n```\n\n### Design Notes\n\n- Self-hosted Integration Runtime requires manual setup outside Bicep\n- Primarily used for on-premises data ingestion scenarios\n- PE groupId: `dataFactory`\n\n---\n\n## 6. AML / AI Hub (MachineLearningServices)\n\n### When to Use\n\n```\nDecision Rule:\n├─ General AI/RAG → Use Foundry (AIServices)\n└─ ML training, open-source models needed → Consider AI Hub\n    └─ Only when the user explicitly requests it\n```\n\n### Bicep Core Structure\n\n```bicep\nresource hub 'Microsoft.MachineLearningServices/workspaces@\u003cfetch\u003e' = {\n  name: hubName\n  location: location\n  kind: 'Hub'\n  sku: { name: '\u003cconfirm with user\u003e', tier: '\u003cconfirm with user\u003e' }  // e.g., Basic/Basic — verify available SKUs in MS Docs\n  identity: { type: 'SystemAssigned' }\n  properties: {\n    friendlyName: hubName\n    storageAccount: storage.id\n    keyVault: keyVault.id\n    applicationInsights: appInsights.id    // Required for Hub\n    publicNetworkAccess: 'Disabled'\n  }\n}\n```\n\n### AI Hub Dependencies\n\nAdditional resources needed when using Hub:\n- Storage Account\n- Key Vault\n- Application Insights + Log Analytics Workspace\n- Container Registry (optional)\n\n---\n\n## 7. Common AI/Data Architecture Combinations\n\n### RAG Chatbot\n\n```\nFoundry (AIServices) + Project\n├── \u003cchat-model\u003e (chat)              — Confirmed after availability check in Phase 1\n├── \u003cembedding-model\u003e (embedding)    — Confirmed after availability check in Phase 1\n├── AI Search (vector + semantic)\n├── ADLS Gen2 (document store)\n└── Key Vault (secrets)\n+ Full VNet/PE configuration\n```\n\n### Data Platform\n\n```\nFabric Capacity (analytics)\n├── ADLS Gen2 (data lake)\n├── ADF (ingestion)\n└── Key Vault (secrets)\n+ VNet/PE configuration\n```\n","references/architecture-guidance-sources.md":"# Architecture Guidance Sources (For Design Direction Decisions)\n\nA source registry for using Azure official architecture guidance **only for design direction decisions**.\n\n\u003e **The URLs in this document are a list of sources for \"where to look\".**\n\u003e Do not hardcode the contents of these URLs as fixed facts.\n\u003e Do not use for SKU, API version, region, model availability, or PE mapping decisions — those are handled exclusively via `azure-dynamic-sources.md`.\n\n---\n\n## Purpose Separation\n\n| Purpose | Document to Use | Decidable Items |\n|---------|----------------|-----------------|\n| **Design direction decisions** | This document (architecture-guidance-sources) | Architecture patterns, best practices, service combination direction, security boundary design |\n| **Deployment spec verification** | `azure-dynamic-sources.md` | API version, SKU, region, model availability, PE groupId, actual property values |\n\n**What must NOT be decided using this document:**\n- API version\n- SKU names/pricing\n- Region availability\n- Model names/versions/deployment types\n- PE groupId / DNS Zone mapping\n- Specific values for resource properties\n\n---\n\n## Primary Sources\n\nTargeted fetch targets for design direction decisions.\n\n| ID | Document | URL | Purpose |\n|----|----------|-----|---------|\n| A1 | Azure Architecture Center | https://learn.microsoft.com/en-us/azure/architecture/ | Hub — Entry point for finding domain-specific documents |\n| A2 | Well-Architected Framework | https://learn.microsoft.com/en-us/azure/architecture/framework/ | Security/reliability/performance/cost/operations principles |\n| A3 | Cloud Adoption Framework / Landing Zone | https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/landing-zone/ | Enterprise governance, network topology, subscription structure |\n| A4 | Azure AI/ML Architecture | https://learn.microsoft.com/en-us/azure/architecture/ai-ml/ | AI/ML workload reference architecture hub |\n| A5 | Basic Foundry Chat Reference Architecture | https://learn.microsoft.com/en-us/azure/architecture/ai-ml/architecture/basic-azure-ai-foundry-chat | Basic Foundry-based chatbot structure |\n| A6 | Baseline AI Foundry Chat Reference Architecture | https://learn.microsoft.com/en-us/azure/architecture/ai-ml/architecture/baseline-openai-e2e-chat | Foundry chatbot enterprise baseline (including network isolation) |\n| A7 | RAG Solution Design Guide | https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-solution-design-and-evaluation-guide | RAG pattern design guide |\n| A8 | Microsoft Fabric Overview | https://learn.microsoft.com/en-us/fabric/get-started/microsoft-fabric-overview | Fabric platform overview and workload understanding |\n| A9 | Fabric Governance / Adoption | https://learn.microsoft.com/en-us/power-bi/guidance/fabric-adoption-roadmap-governance | Fabric governance, adoption roadmap |\n\n## Secondary Sources (awareness only)\n\nNot direct fetch targets; referenced only for change awareness.\n\n| Document | URL | Notes |\n|----------|-----|-------|\n| Azure Updates | https://azure.microsoft.com/en-us/updates/ | Service changes/new feature announcements. Not a targeted fetch target |\n\n---\n\n## Fetch Trigger — When to Query\n\nArchitecture guidance documents are **not queried on every request.** Only perform targeted fetch when the following triggers apply.\n\n### Trigger Conditions\n\n0. **When the user's workload type is identified in Phase 1 (automatic)**\n   - Pre-query the relevant workload's reference architecture to adjust question depth\n   - Triggers automatically even if the user doesn't mention \"best practice\" etc.\n   - Purpose: Reflect official architecture-based design decision points in questions, beyond SKU/region spec questions\n1. **When the user requests design direction justification**\n   - Keywords such as \"best practice\", \"reference architecture\", \"recommended structure\", \"baseline\", \"well-architected\", \"landing zone\", \"enterprise pattern\"\n2. **When architecture boundaries for a new service combination are ambiguous**\n   - Inter-service relationships that cannot be determined from existing reference files/service-gotchas\n3. **When enterprise-level security/governance design is needed**\n   - Subscription structure, network topology, landing zone patterns\n\n### When Triggers Do Not Apply\n\n- Simple resource creation (SKU/API version/region questions) → Use only `azure-dynamic-sources.md`\n- Service combinations already covered in domain-packs → Prioritize reference files\n- Bicep property value verification → `service-gotchas.md` or MS Docs Bicep reference\n\n---\n\n## Fetch Budget\n\n| Scenario | Max Fetch Count |\n|----------|----------------|\n| Default (when trigger fires) | Architecture guidance documents **up to 2** |\n| Additional fetch allowed when | Conflicts between documents / core design uncertainty remains / user explicitly requests deeper justification |\n| Simple deployment spec questions | **0** (no architecture guidance queries) |\n\n---\n\n## Decision Rule by Question Type\n\n| Question Type | Documents to Query | Design Decision Points to Extract | Documents NOT to Query |\n|--------------|-------------------|----------------------------------|----------------------|\n| RAG / chatbot / Foundry app | A5 or A6 + A7 | Network isolation level, authentication method (managed identity vs key), indexing strategy (push vs pull), monitoring scope | Do not traverse entire Architecture Center |\n| Enterprise security / governance / landing zone | A2 + A3 | Subscription structure, network topology (hub-spoke etc.), identity/governance model, security boundary | AI/ML domain documents not needed |\n| Fabric data platform | A8 + A9 | Capacity model (SKU selection criteria), governance level, data boundary (workspace separation etc.) | AI-related documents not needed |\n| Ambiguous service combination (unclear pattern) | A1 (find closest domain document from hub) + that document | Key design decision points identified from the document | Do not traverse all sub-documents |\n| Simple resource creation values (SKU/API/region) | No query | — | All architecture guidance |\n| General AI/ML architecture | A4 (hub) + closest reference architecture | Compute isolation, data boundary, model serving approach | Do not crawl entirely |\n\n---\n\n## URL Fallback Rule\n\n1. Use `en-us` Learn URLs by default\n2. If a specific URL returns 404 / redirect / deprecated → Fall back to the parent hub page\n   - Example: If A5 fails → Search for \"foundry chat\" keyword on A4 (AI/ML hub)\n3. If not found on the parent hub either → Search by title keyword on A1 (Architecture Center main)\n4. **Do not use the contents of a URL as fixed rules just because the URL exists**\n\n---\n\n## Full Traversal Prohibited\n\n- Do not broadly traverse (crawl) Architecture Center sub-documents\n- Only targeted fetch 1–2 related documents according to the decision rule by question type\n- Even within fetched documents, only reference relevant sections; do not read the entire document\n- Unlimited fetching, recursive link following, and sub-page enumeration are prohibited\n","references/azure-common-patterns.md":"# Azure Common Patterns (Stable)\n\nThis file contains only **near-immutable patterns** that are repeated across Azure services.\nDynamic information such as API version, SKU, and region is not included here → See `azure-dynamic-sources.md`.\n\n---\n\n## 1. Network Isolation Patterns\n\n### Private Endpoint 3-Component Set\n\nAll services using PE must have the 3-component set configured:\n\n1. **Private Endpoint** — Placed in pe-subnet\n2. **Private DNS Zone** + **VNet Link** (`registrationEnabled: false`)\n3. **DNS Zone Group** — Linked to PE\n\n\u003e If any one is missing, DNS resolution fails even with PE present, causing connection failure.\n\n### PE Subnet Required Settings\n\n```bicep\nresource peSubnet 'Microsoft.Network/virtualNetworks/subnets' = {\n  properties: {\n    addressPrefix: peSubnetPrefix              // ← CIDR as parameter — prevent existing network conflicts\n    privateEndpointNetworkPolicies: 'Disabled'  // ← Required. PE deployment fails without it\n  }\n}\n```\n\n### publicNetworkAccess Pattern\n\nServices using PE must include:\n```bicep\nproperties: {\n  publicNetworkAccess: 'Disabled'\n  networkAcls: {\n    defaultAction: 'Deny'\n  }\n}\n```\n\n---\n\n## 2. Security Patterns\n\n### Key Vault\n\n```bicep\nproperties: {\n  enableRbacAuthorization: true    // Do not use Access Policy method\n  enableSoftDelete: true\n  softDeleteRetentionInDays: 90\n  enablePurgeProtection: true\n}\n```\n\n### Managed Identity\n\nWhen AI services access other resources:\n```bicep\nidentity: {\n  type: 'SystemAssigned'  // or 'UserAssigned'\n}\n```\n\n### Sensitive Information\n\n- Use `@secure()` decorator\n- Do not store plaintext in `.bicepparam` files\n- Use Key Vault references\n\n---\n\n## 3. Naming Conventions (CAF-based)\n\n```\nrg-{project}-{env}          Resource Group\nvnet-{project}-{env}        Virtual Network\nst{project}{env}             Storage Account (no special characters, lowercase+numbers only)\nkv-{project}-{env}           Key Vault\nsrch-{project}-{env}         AI Search\nfoundry-{project}-{env}      Cognitive Services (Foundry)\n```\n\n\u003e Name collision prevention: Recommend using `uniqueString(resourceGroup().id)`\n\u003e ```bicep\n\u003e param storageName string = 'st${uniqueString(resourceGroup().id)}'\n\u003e ```\n\n---\n\n## 4. Bicep Module Structure\n\n```\n\u003cproject\u003e/\n├── main.bicep              # Orchestration — module calls + parameter passing\n├── main.bicepparam         # Environment-specific values (excluding sensitive info)\n└── modules/\n    ├── network.bicep           # VNet, Subnet\n    ├── \u003cservice\u003e.bicep         # Per-service modules\n    ├── keyvault.bicep          # Key Vault\n    └── private-endpoints.bicep # All PE + DNS Zone + VNet Link\n```\n\n### Dependency Management\n\n```bicep\n// ✅ Correct: Implicit dependency via resource reference\nresource project '...' = {\n  properties: {\n    parentId: foundry.id  // foundry reference → automatically deploys foundry first\n  }\n}\n\n// ❌ Avoid: Explicit dependsOn (use only when necessary)\n```\n\n---\n\n## 5. PE Bicep Common Template\n\n```bicep\n// ── Private Endpoint ──\nresource pe 'Microsoft.Network/privateEndpoints@\u003cfetch\u003e' = {\n  name: 'pe-${serviceName}'\n  location: location\n  properties: {\n    subnet: { id: peSubnetId }\n    privateLinkServiceConnections: [{\n      name: 'pls-${serviceName}'\n      properties: {\n        privateLinkServiceId: serviceId\n        groupIds: ['\u003cgroupId\u003e']  // ← Varies by service. See service-gotchas.md\n      }\n    }]\n  }\n}\n\n// ── Private DNS Zone ──\nresource dnsZone 'Microsoft.Network/privateDnsZones@\u003cfetch\u003e' = {\n  name: '\u003cdnsZoneName\u003e'  // ← Varies by service\n  location: 'global'\n}\n\n// ── VNet Link ──\nresource vnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@\u003cfetch\u003e' = {\n  parent: dnsZone\n  name: '${dnsZone.name}-link'\n  location: 'global'\n  properties: {\n    virtualNetwork: { id: vnetId }\n    registrationEnabled: false  // ← Must be false\n  }\n}\n\n// ── DNS Zone Group ──\nresource dnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@\u003cfetch\u003e' = {\n  parent: pe\n  name: 'default'\n  properties: {\n    privateDnsZoneConfigs: [{\n      name: 'config'\n      properties: { privateDnsZoneId: dnsZone.id }\n    }]\n  }\n}\n```\n\n\u003e `@\u003cfetch\u003e`: Always verify the latest stable API version from MS Docs before deployment.\n","references/azure-dynamic-sources.md":"# Azure Dynamic Sources Registry\n\nThis file manages **only the sources (URLs) for frequently changing information**.\nActual values (API version, SKU, region, etc.) are not recorded here.\nAlways fetch the URLs below to verify the latest information before generating Bicep.\n\n---\n\n## 1. Bicep API Version (Always Must Fetch)\n\nPer-service MS Docs Bicep reference. Verify the latest stable apiVersion from these URLs before use.\n\n| Service | MS Docs URL |\n|---------|-------------|\n| CognitiveServices (Foundry/OpenAI) | https://learn.microsoft.com/en-us/azure/templates/microsoft.cognitiveservices/accounts |\n| AI Search | https://learn.microsoft.com/en-us/azure/templates/microsoft.search/searchservices |\n| Storage Account | https://learn.microsoft.com/en-us/azure/templates/microsoft.storage/storageaccounts |\n| Key Vault | https://learn.microsoft.com/en-us/azure/templates/microsoft.keyvault/vaults |\n| Virtual Network | https://learn.microsoft.com/en-us/azure/templates/microsoft.network/virtualnetworks |\n| Private Endpoints | https://learn.microsoft.com/en-us/azure/templates/microsoft.network/privateendpoints |\n| Private DNS Zones | https://learn.microsoft.com/en-us/azure/templates/microsoft.network/privatednszones |\n| Fabric | https://learn.microsoft.com/en-us/azure/templates/microsoft.fabric/capacities |\n| Data Factory | https://learn.microsoft.com/en-us/azure/templates/microsoft.datafactory/factories |\n| Application Insights | https://learn.microsoft.com/en-us/azure/templates/microsoft.insights/components |\n| ML Workspace (Hub) | https://learn.microsoft.com/en-us/azure/templates/microsoft.machinelearningservices/workspaces |\n\n\u003e **Always verify child resources as well**: Child resources such as `accounts/projects`, `accounts/deployments`, `privateDnsZones/virtualNetworkLinks` may have different API versions from their parent. Follow child resource links from the parent page to verify.\n\n### Services Not in the Table Above\n\nThe table above includes only v1 scope services. For other services, construct the URL in this format and fetch:\n```\nhttps://learn.microsoft.com/en-us/azure/templates/microsoft.{provider}/{resourceType}\n```\n\n---\n\n## 2. Model Availability (Required When Using Foundry/OpenAI Models)\n\nVerify whether the model name is deployable in the target region. Do not rely on static knowledge.\n\n| Verification Method | URL / Command |\n|--------------------|---------------|\n| MS Docs model availability | https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models |\n| Azure CLI (existing resources) | `az cognitiveservices account list-models --name \"\u003cNAME\u003e\" --resource-group \"\u003cRG\u003e\" -o table` |\n\n\u003e If the model is unavailable in the target region → Notify the user and suggest available regions/alternative models. Do not substitute without user approval.\n\n---\n\n## 3. Private Endpoint Mapping (When Adding New Services)\n\nPE groupId and DNS Zone mappings can be changed by Azure. When adding new services or verification is needed:\n\n| Verification Method | URL |\n|--------------------|-----|\n| PE DNS integration official docs | https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns |\n\n\u003e Key service mappings in `service-gotchas.md` are stable, but always re-verify from the URL above when adding new services.\n\n---\n\n## 4. Service Region Availability\n\nVerify whether a specific service is available in a specific region:\n\n| Verification Method | URL |\n|--------------------|-----|\n| Azure service-by-region availability | https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-region/ |\n\n---\n\n## 5. Azure Updates (Secondary Awareness)\n\nThe sources below are for **reference only**. The primary source is always MS Docs official documentation.\n\n| Source | URL | Purpose |\n|--------|-----|---------|\n| Azure Updates | https://azure.microsoft.com/en-us/updates/ | Service change awareness |\n| What's New in Azure | Per-service What's New pages in Docs | Feature change verification |\n\n---\n\n## Decision Rule: When to Fetch?\n\n| Information Type | Must Fetch? | Rationale |\n|-----------------|-------------|-----------|\n| API version | **Always fetch** | Changes frequently; incorrect values cause deployment failure |\n| Model availability (name, region) | **Always fetch** | Varies by region and changes frequently |\n| SKU list | **Always fetch** | Can change per service |\n| Region availability | **Always fetch** | Per-service region support changes frequently. Always verify that the user-specified region is available for the service |\n| PE groupId \u0026 DNS Zone | Can reference `service-gotchas.md` for v1 key services; **must fetch for new services or complex configurations (Monitor, etc.)** | Key service mappings are stable, but new/complex services are risky |\n| Required property patterns | Reference files first | Near-immutable (isHnsEnabled, etc.) |\n","references/bicep-generator.md":"# Bicep Generator Agent\n\nReceives the finalized architecture spec from Phase 1 and generates deployable Bicep templates.\n\n## Step 0: Verify Latest Specs (Required Before Bicep Generation)\n\nDo not hardcode API versions in Bicep code.\nAlways fetch the MS Docs Bicep reference for the services you intend to use and confirm the latest stable apiVersion before using it.\n\n### Verification Steps\n1. Identify the list of services to be used\n2. Fetch the MS Docs URL for each service (using the web_fetch tool)\n3. Confirm the latest stable API version from the page\n4. Write Bicep using that version\n\n### Model Deployment Availability Check (Required When Using Foundry/OpenAI Models)\n\nVerify that the model name specified by the user is actually deployable in the target region **before generating Bicep**.\nModel availability varies by region and changes frequently — do not rely on static knowledge.\n\n**Verification Methods (in priority order):**\n1. Check the MS Docs model availability page: https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models\n2. Or query directly via Azure CLI:\n   ```powershell\n   az cognitiveservices account list-models --name \"\u003cFOUNDRY_NAME\u003e\" --resource-group \"\u003cRG_NAME\u003e\" -o table\n   ```\n   (When the Foundry resource already exists)\n\n**If the model is not available in the target region:**\n- Inform the user and suggest available regions or alternative models\n- Do not substitute a different model or region without user approval\n\n### Per-Service MS Docs URLs\n\nThe full URL registry is in `references/azure-dynamic-sources.md`. Refer to this file when fetching.\nReference files are located under the `.github/skills/azure-architecture-autopilot/` path.\n\n\u003e **Important**: Fetch directly from the URL using web_fetch to confirm the latest stable apiVersion. Do not blindly use hardcoded versions from reference files or previous conversations.\n\n\u003e **Always verify child resources too**: Check the API versions for child resources (accounts/projects, accounts/deployments, privateDnsZones/virtualNetworkLinks, privateEndpoints/privateDnsZoneGroups, etc.) from the parent resource page. Parent and child API versions may differ.\n\n\u003e **Same principle applies when errors/warnings occur**: If an API version–related error occurs during what-if or deployment, do not trust the version in the error message as the \"latest version\" and apply it directly. Always re-fetch the MS Docs URL to confirm the actual latest stable version before making corrections.\n\n---\n\n## Information Reference Principles (Stable vs Dynamic)\n\n### Always Fetch (Dynamic)\n- API version → Fetch from URLs in `azure-dynamic-sources.md`\n- Model availability (name, version, region) → Fetch\n- SKU list/pricing → Fetch\n- Region availability → Fetch\n\n### Reference First (Stable)\n- Required property patterns (`isHnsEnabled`, `allowProjectManagement`, etc.) → `service-gotchas.md`\n- PE groupId \u0026 DNS Zone mappings (major services) → `service-gotchas.md`\n- PE/security/naming common patterns → `azure-common-patterns.md`\n- AI/Data service configuration guide → `ai-data.md`\n\n\u003e If unsure about stable information, re-verify with MS Docs. But there is no need to fetch every time.\n\n---\n\n## Unknown Service Fallback Workflow\n\nWhen the user requests a service not covered by the v1 scope (`ai-data.md`):\n\n1. **Notify the user**: \"This service is outside the v1 default scope. It will be generated on a best-effort basis by referencing MS Docs.\"\n2. **Fetch API version**: Construct the URL in the format `https://learn.microsoft.com/en-us/azure/templates/microsoft.{provider}/{resourceType}` and fetch\n3. **Identify resource type/required properties**: Confirm the resource type and required properties from the fetched Docs\n4. **Verify PE mapping**: Fetch `https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns` to confirm groupId/DNS Zone\n5. **Apply common patterns**: Apply security/network/naming patterns from `azure-common-patterns.md`\n6. **Write Bicep**: Generate the module based on the above information\n7. **Hand off to reviewer**: Validate compilation with `az bicep build`\n\n## Input Information\n\nThe following information must be finalized upon completion of Phase 1:\n\n```\n- services: [Service list + SKU]\n- networking: Whether private_endpoint is used\n- resource_group: Resource group name\n- location: Deployment location (confirmed with user in Phase 1)\n- subscription_id: Azure subscription ID\n```\n\n## Output File Structure\n\n```\n\u003cproject-name\u003e/\n├── main.bicep              # Main orchestration — module calls and parameter passing\n├── main.bicepparam         # Parameter file — environment-specific values, excluding sensitive info\n└── modules/\n    ├── network.bicep           # VNet, Subnet (including pe-subnet)\n    ├── ai.bicep                # AI services (configured per user requirements)\n    ├── storage.bicep           # ADLS Gen2 (isHnsEnabled: true required)\n    ├── fabric.bicep            # Microsoft Fabric Capacity (only when needed)\n    ├── keyvault.bicep          # Key Vault\n    ├── monitoring.bicep        # Application Insights, Log Analytics (only needed for Hub-based configurations)\n    └── private-endpoints.bicep # All PEs + Private DNS Zones + VNet Links + DNS Zone Groups\n```\n\n## Module Responsibilities\n\n### `network.bicep`\n- VNet — CIDR received as a parameter (to avoid conflicts with existing address spaces in the customer environment)\n- pe-subnet — `privateEndpointNetworkPolicies: 'Disabled'` required\n- Additional subnets handled via parameters as needed\n\n### `ai.bicep`\n- **Microsoft Foundry resource** (`Microsoft.CognitiveServices/accounts`, `kind: 'AIServices'`) — Top-level AI resource\n  - `customSubDomainName: foundryName` required — **Cannot be changed after creation. If omitted, the resource must be deleted and recreated**\n  - `identity: { type: 'SystemAssigned' }` required\n  - `allowProjectManagement: true` required\n  - Model deployment (`Microsoft.CognitiveServices/accounts/deployments`) — Performed at the Foundry resource level\n- **⚠️ Foundry Project** (`Microsoft.CognitiveServices/accounts/projects`) — **Must be created as a child resource**\n  - Resource type: `Microsoft.CognitiveServices/accounts/projects` (never create as a standalone `accounts` resource)\n  - Use `parent: foundryAccount` in Bicep\n  - Incorrect example: Creating a Project as a separate `kind: 'AIServices'` account → Not recognized in the portal\n  - Correct example:\n    ```bicep\n    resource foundryProject 'Microsoft.CognitiveServices/accounts/projects@\u003capiVersion\u003e' = {\n      parent: foundryAccount\n      name: 'project-${uniqueString(resourceGroup().id)}'\n      location: location\n      kind: 'AIServices'\n      properties: {}\n    }\n    ```\n- **Azure AI Search** — Semantic Ranking, vector search configuration\n- Hub-based (`Microsoft.MachineLearningServices/workspaces`) should only be considered when the user explicitly requests it or when ML training/open-source models are needed. For standard AI/RAG workloads, Foundry (AIServices) is the default choice\n\n**⛔ CognitiveServices Prohibited Properties:**\n- `apiProperties.statisticsEnabled` — This property does not exist. Never use it. Causes `ApiPropertiesInvalid` error during deployment\n- `apiProperties.qnaAzureSearchEndpointId` — QnA Maker only. Do not use with Foundry\n- Do not arbitrarily add unvalidated properties to `properties.apiProperties`\n\n### `storage.bicep`\n- ADLS Gen2: `isHnsEnabled: true` ← **Never omit this**\n- Containers: raw, processed, curated (or as per requirements)\n- `allowBlobPublicAccess: false`, `minimumTlsVersion: 'TLS1_2'`\n\n### `keyvault.bicep`\n- `enableRbacAuthorization: true` (do not use access policy model)\n- `enableSoftDelete: true`, `softDeleteRetentionInDays: 90`\n- `enablePurgeProtection: true`\n\n### `monitoring.bicep`\n- Log Analytics Workspace\n- Application Insights (only needed for Hub-based configurations — not required for Foundry AIServices)\n\n### `private-endpoints.bicep`\n- 3-piece set for each service:\n  1. `Microsoft.Network/privateEndpoints` (placed in pe-subnet)\n  2. `Microsoft.Network/privateDnsZones` + VNet Link (`registrationEnabled: false`)\n  3. `Microsoft.Network/privateEndpoints/privateDnsZoneGroups`\n- For per-service DNS Zone mappings, refer to `references/service-gotchas.md`\n\n**⚠️ Foundry/AIServices PE DNS Rules:**\n- PE groupId: `account`\n- DNS Zone Group must include **2 zones**:\n  1. `privatelink.cognitiveservices.azure.com`\n  2. `privatelink.openai.azure.com`\n- Including only one causes DNS resolution failure for OpenAI API calls → connection error\n\n**⚠️ ADLS Gen2 (isHnsEnabled: true) PE Rules:**\n- 2 PEs required:\n  1. `blob` → `privatelink.blob.core.windows.net`\n  2. `dfs` → `privatelink.dfs.core.windows.net`\n- Without the DFS PE, Data Lake operations (file system creation, directory manipulation) will fail\n\n### `rbac.bicep` (or inline in main.bicep)\n\n**⚠️ RBAC Role Assignment — Never Omit**\n\n**Any service with a Managed Identity (`identity.type: 'SystemAssigned'`) must have RBAC role assignments created.**\nHaving an identity without role assignments causes inter-service authentication failures.\nThis is not optional — it is a **mandatory item**.\nOmission will be reported as CRITICAL in Phase 3 review.\n\n- Required RBAC mappings:\n\n| Source Service | Target Service | Role | Role Definition ID |\n|------------|-----------|------|-------------------|\n| Foundry | Storage | `Storage Blob Data Contributor` | `ba92f5b4-2d11-453d-a403-e96b0029c9fe` |\n| Foundry | AI Search | `Search Index Data Contributor` | `8ebe5a00-799e-43f5-93ac-243d3dce84a7` |\n| Foundry | AI Search | `Search Service Contributor` | `7ca78c08-252a-4471-8644-bb5ff32d4ba0` |\n| App Service | Key Vault | `Key Vault Secrets User` | `4633458b-17de-408a-b874-0445c86b69e6` |\n| AKS (kubeletIdentity) | ACR | `AcrPull` | `7f951dda-4ed3-4680-a7ca-43fe172d538d` |\n| Data Factory | Storage | `Storage Blob Data Contributor` | `ba92f5b4-2d11-453d-a403-e96b0029c9fe` |\n| Data Factory | Key Vault | `Key Vault Secrets User` | `4633458b-17de-408a-b874-0445c86b69e6` |\n| Databricks | Storage | `Storage Blob Data Contributor` | `ba92f5b4-2d11-453d-a403-e96b0029c9fe` |\n\n\u003e **AKS Special Rule**: AKS uses `identityProfile.kubeletidentity.objectId`, not `identity.principalId`.\n\n```bicep\n// RBAC Example — Foundry → Storage Blob Data Contributor\nresource foundryStorageRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {\n  name: guid(storageAccount.id, foundry.id, 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')\n  scope: storageAccount\n  properties: {\n    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')\n    principalId: foundry.identity.principalId\n    principalType: 'ServicePrincipal'\n  }\n}\n```\n\n### SQL Server Rules\n- **Password management**: Declare `@secure() param sqlAdminPassword string` in main.bicep and pass it to modules\n  - Do not generate with `newGuid()` inside modules — the password changes on redeployment\n  - Store as a Key Vault Secret so it can be retrieved after deployment\n- **Authentication method**: Default to `administrators.azureADOnlyAuthentication: true`\n  - Many organizational policies (MCAPS, etc.) block standalone SQL authentication\n  - AAD-only authentication + Managed Identity is the most secure configuration\n\n### Network Secret Handling\n- **VPN Gateway shared key**: `@secure() param vpnSharedKey string` — `@secure()` is mandatory\n- Never include plaintext VPN keys in `.bicepparam` — provide at deployment time or use Key Vault reference\n- This rule applies the same as for SQL passwords\n- **Applies to**: VPN shared key, ExpressRoute authorization key, Wi-Fi PSK, and all other network secrets\n- Module params must also include the `@secure()` decorator\n\n### ⚠️ Network Isolation Consistency Rules\n- When setting `publicNetworkAccess: 'Disabled'`, you **must** also create the corresponding PE for that service\n- Setting publicNetworkAccess to Disabled without a PE makes the service unreachable → unusable after deployment\n- The Phase 3 reviewer must report this inconsistency as **CRITICAL**\n- When an inconsistency is found: either add a PE module or revert publicNetworkAccess to Enabled\n\n## Mandatory Coding Principles\n\n### Naming Conventions\n```bicep\n// Use uniqueString to prevent naming collisions — always required\nparam foundryName string = 'foundry-${uniqueString(resourceGroup().id)}'\nparam searchName string = 'srch-${uniqueString(resourceGroup().id)}'\nparam storageName string = 'st${uniqueString(resourceGroup().id)}'  // No special characters allowed\nparam keyVaultName string = 'kv-${uniqueString(resourceGroup().id)}'\n```\n\u003e **⚠️ Resources requiring `customSubDomainName` (Foundry, Cognitive Services, etc.) must include `uniqueString()`.**\n\u003e Static strings (e.g., `'my-rag-chatbot'`) may already be in use by another tenant, causing deployment failures.\n\u003e The same applies to Foundry Project names — `'project-${uniqueString(resourceGroup().id)}'`\n\n### Network Isolation\n```bicep\n// Required for all services when using Private Endpoints\npublicNetworkAccess: 'Disabled'\nnetworkAcls: {\n  defaultAction: 'Deny'\n  ipRules: []\n  virtualNetworkRules: []\n}\n```\n\n### Dependency Management\n```bicep\n// Use implicit dependencies via resource references instead of explicit dependsOn\nresource aiProject '...' = {\n  properties: {\n    hubResourceId: aiHub.id  // Reference to aiHub → aiHub is automatically deployed first\n  }\n}\n```\n\n### Security\n```bicep\n// Use Key Vault references for sensitive values — never store plaintext in parameter files\n@secure()\nparam adminPassword string  // Do not put plaintext values in main.bicepparam\n```\n\n### Code Comments\n```bicep\n// Microsoft Foundry resource — kind: 'AIServices'\n// customSubDomainName: Required, globally unique. Cannot be changed after creation — if omitted, resource must be deleted and recreated\n// allowProjectManagement: true is required or Foundry Project creation will fail\n// Replace apiVersion with the latest version fetched in Step 0\nresource foundry 'Microsoft.CognitiveServices/accounts@\u003cversion fetched in Step 0\u003e' = {\n  kind: 'AIServices'\n  properties: {\n    customSubDomainName: foundryName\n    allowProjectManagement: true\n    ...\n  }\n}\n```\n\n### ⚠️ Bicep Code Quality Validation (Required After Generation)\n\n**Module Declaration Validation:**\n- Verify that the `name:` property in each module block is not duplicated\n- Correct example: `name: 'deploy-sql'`\n- Incorrect example: `name: 'name: 'deploy-sql'` (duplicated name: → compilation error)\n\n**Duplicate Property Prevention:**\n- If the same property name appears more than once within a single resource block, it causes a compilation error\n- Especially common in complex resources like VPN Gateway (`gatewayType`), Firewall, AKS, etc.\n- Check for `BCP025: The property \"xxx\" is declared multiple times` in the `az bicep build` output\n\n**`az bicep build` Must Be Run:**\n- After generating all Bicep files, always run `az bicep build --file main.bicep`\n- Fix errors and recompile\n- Warnings (BCP081, etc.) can be ignored after verifying the API version in MS Docs\n\n## main.bicep Base Structure\n\n```bicep\n// ============================================================\n// Azure [Project Name] Infrastructure — main.bicep\n// Generated: [Date]\n// ============================================================\n\ntargetScope = 'resourceGroup'\n\n// ── Common Parameters ─────────────────────────────────────\nparam location string   // Location confirmed in Phase 1 — do not hardcode\nparam projectPrefix string\nparam vnetAddressPrefix string    // ← Confirm with user. Prevent conflicts with existing networks\nparam peSubnetPrefix string       // ← PE-dedicated subnet CIDR within the VNet\n\n// ── Network ───────────────────────────────────────────────\nmodule network './modules/network.bicep' = {\n  name: 'deploy-network'\n  params: {\n    location: location\n    vnetAddressPrefix: vnetAddressPrefix\n    peSubnetPrefix: peSubnetPrefix\n  }\n}\n\n// ── AI/Data Services ──────────────────────────────────────\nmodule ai './modules/ai.bicep' = {\n  name: 'deploy-ai'\n  params: {\n    location: location\n    // Add separate params if regions differ per service — verify available regions in MS Docs\n  }\n  dependsOn: [network]\n}\n\n// ── Storage ───────────────────────────────────────────────\nmodule storage './modules/storage.bicep' = {\n  name: 'deploy-storage'\n  params: {\n    location: location\n  }\n}\n\n// ── Key Vault ─────────────────────────────────────────────\nmodule keyVault './modules/keyvault.bicep' = {\n  name: 'deploy-keyvault'\n  params: {\n    location: location\n  }\n}\n\n// ── Private Endpoints (All Services) ──────────────────────\nmodule privateEndpoints './modules/private-endpoints.bicep' = {\n  name: 'deploy-private-endpoints'\n  params: {\n    location: location\n    vnetId: network.outputs.vnetId\n    peSubnetId: network.outputs.peSubnetId\n    foundryId: ai.outputs.foundryId\n    searchId: ai.outputs.searchId\n    storageId: storage.outputs.storageId\n    keyVaultId: keyVault.outputs.keyVaultId\n  }\n}\n\n// ── Outputs ───────────────────────────────────────────────\noutput vnetId string = network.outputs.vnetId\noutput foundryEndpoint string = ai.outputs.foundryEndpoint\noutput searchEndpoint string = ai.outputs.searchEndpoint\n```\n\n## main.bicepparam Base Structure\n\n```bicep\nusing './main.bicep'\n\nparam location = '\u003cLocation confirmed in Phase 1\u003e'\nparam projectPrefix = '\u003cProject prefix\u003e'\n// Do not put sensitive values here — use Key Vault references\n// Set regions after verifying per-service availability in MS Docs\n```\n\n### @secure() Parameter Handling\n\nWhen a `.bicepparam` file contains a `using` directive, additional `--parameters` flags cannot be used with `az deployment`.\nTherefore, `@secure()` parameters must follow these rules:\n\n- **Set a default value when possible**: `@secure() param password string = newGuid()`\n- **If user input is required for @secure() parameters**: Generate a JSON parameter file (`main.parameters.json`) alongside instead of using `.bicepparam`\n- **Never do this**: Generate a command that uses `.bicepparam` and `--parameters key=value` simultaneously\n\n## Common Mistake Checklist\n\nThe full checklist is in `references/service-gotchas.md`. Key summary:\n\n| Item | ❌ Incorrect | ✅ Correct |\n|------|--------|----------|\n| ADLS Gen2 | `isHnsEnabled` omitted | `isHnsEnabled: true` |\n| PE Subnet | Policy not set | `privateEndpointNetworkPolicies: 'Disabled'` |\n| PE Configuration | PE only created | PE + DNS Zone + VNet Link + DNS Zone Group |\n| Foundry | `kind: 'OpenAI'` | `kind: 'AIServices'` + `allowProjectManagement: true` |\n| Foundry | `customSubDomainName` omitted | `customSubDomainName: foundryName` — cannot be changed after creation |\n| Foundry Project | Not created | Must always be created as a set with the Foundry resource |\n| Hub Usage | Used for standard AI | Only when explicitly requested by user or ML/open-source models needed |\n| Public Network | Not configured | `publicNetworkAccess: 'Disabled'` |\n| Storage Name | Contains hyphens | Lowercase + digits only, `uniqueString()` recommended |\n| API version | Copied from previous value | Fetch from MS Docs (Dynamic) |\n| Region | Hardcoded | Parameter + verify availability in MS Docs (Dynamic) |\n\n## After Generation Is Complete\n\nWhen Bicep generation is complete:\n1. Provide the user with a summary report of the generated file list and each file's role\n2. Immediately transition to Phase 3 (Bicep Reviewer)\n3. The reviewer proceeds with automated review and corrections following the `references/bicep-reviewer.md` guidelines\n","references/bicep-reviewer.md":"# Bicep Reviewer Agent\n\nReviews generated Bicep code and automatically fixes any issues found.\n\n## Review Order\n\n### Step 1: Bicep Compilation (Run First)\n\nRun actual Bicep compilation **before** the checklist. Do not declare \"pass\" based on visual inspection alone.\n\n```powershell\naz bicep build --file main.bicep 2\u003e\u00261\n```\n\nCollect all WARNINGs and ERRORs from the compilation results. This is the foundational data for the review.\n\n### Step 2: Fix Compilation Errors/Warnings\n\nFix issues found in compilation results:\n- **ERROR** → Must fix and recompile\n- **WARNING** → Handle according to the criteria below\n\n**🚨 WARNING Handling Criteria — Do Not Force Unnecessary Fixes:**\n\nWARNINGs do not block deployment. Attempting to resolve warnings often introduces deployment errors, so use the following criteria:\n\n| WARNING Type | Action | Reason |\n|---|---|---|\n| BCP081 (type not defined) | **Leave as-is** (if API version is the latest confirmed from MS Docs) | Local Bicep CLI type definitions are not yet updated. No impact on deployment |\n| BCP035 (missing property) | **Judge carefully** — Check MS Docs to verify if the property is actually required; if not, leave as-is | Adding properties can cause deployment failures due to compatibility issues (e.g., computeMode) |\n| BCP187 (sku/kind type unverified) | **Leave as-is** | Values confirmed from MS Docs will work correctly at deployment |\n| no-hardcoded-env-urls | **Leave as-is** | DNS Zone names inevitably require hardcoding |\n\n**Never do the following:**\n- Downgrade API versions to resolve WARNINGs (maintain latest stable)\n- Add properties not confirmed in MS Docs to resolve WARNINGs\n- Force fixes targeting \"zero warnings\"\n\n**Principle: Document WARNINGs in review results, but do not fix them if they don't block deployment.**\n\nCommon issues and responses:\n- BCP081 (type not defined) → API version is likely incorrect. Fetch MS Docs and update to the actual latest stable version\n- BCP036 (type mismatch) → Check property value casing and type, then fix\n- BCP037 (property not allowed) → Check MS Docs to verify if the property is supported in that API version\n- no-hardcoded-env-urls → Hardcoded URLs in DNS Zone names etc. are sometimes unavoidable in Bicep. Note in review results\n\n### Step 3: Checklist Review\n\nReview the following items after compilation passes. See `references/service-gotchas.md` for full gotchas.\n\n#### Critical (Must Fix)\n- [ ] Microsoft Foundry `customSubDomainName` setting exists — **Cannot be changed after creation; if missing, resource must be deleted and recreated**\n- [ ] When using Microsoft Foundry, **Foundry Project (`accounts/projects`) must exist** — Portal access unavailable without it\n- [ ] Microsoft Foundry `identity: { type: 'SystemAssigned' }` — Project creation fails without it\n- [ ] `publicNetworkAccess: 'Disabled'` — All services using PE\n- [ ] ADLS Gen2 `isHnsEnabled: true` — Without it, becomes regular Blob Storage\n- [ ] pe-subnet `privateEndpointNetworkPolicies: 'Disabled'` — PE creation fails without it\n- [ ] Private DNS Zone Group — Must exist for every PE\n- [ ] Key Vault `enablePurgeProtection: true`\n\n#### High (Recommended Fix)\n- [ ] Storage `allowBlobPublicAccess: false`, `minimumTlsVersion: 'TLS1_2'`\n- [ ] Private DNS Zone VNet Link `registrationEnabled: false`\n- [ ] Resource types and kind values per service match `references/ai-data.md` or MS Docs\n- [ ] Model deployments: Order guaranteed (`dependsOn`)\n- [ ] No sensitive values in parameter files — **Remove immediately if found**\n\n#### Medium (Recommended)\n- [ ] Resource name collision prevention using `uniqueString()`\n- [ ] Leverage implicit dependencies through resource references\n\n### Step 4: Hardcoding Regression Check (Prevent Dynamic Information Leakage)\n\nVerify the following items are not hardcoded as literal values in the Bicep code:\n\n#### Must Be Parameterized (No Hardcoding)\n- [ ] `location` — Literal region names (`'eastus'`, `'koreacentral'`, etc.) are not used directly; passed via `param location`\n- [ ] Model name/version — Not literals; use values confirmed in Phase 1 and validated for availability in Step 0\n- [ ] SKU — Use values confirmed with the user\n\n#### Verify Dynamic Values Have Not Regressed Into References\nThis is not directly within this review's scope, but if specific API versions, SKU lists, or region lists are hardcoded in code comments or parameter descriptions, remove them and replace with \"Check MS Docs\" guidance.\n\n#### Decision Rule Violation Check\n- [ ] If `kind: 'OpenAI'` is used instead of Foundry → Change to `kind: 'AIServices'` unless the user explicitly requested it\n- [ ] If Hub (`MachineLearningServices`) is used for general AI/RAG → Change to Foundry unless the user explicitly requested it\n- [ ] If a standalone Azure OpenAI resource is used → Suggest reviewing Foundry usage unless the user explicitly requested it or Docs indicate it's necessary\n\n### Step 5: Recompile After Fixes\n\nIf any changes were made in Steps 2–4, run `az bicep build` again to verify no new errors were introduced.\n\n### Limitations of `az bicep build`\n\nCompilation only validates syntax and types. The following items cannot be caught by compilation and are finally verified in Phase 4's `az deployment group what-if`:\n- Retired/unavailable SKU\n- Per-region service availability\n- Model name validity\n- Preview-only properties\n- Service policy changes (quota, capacity, etc.)\n\nState these limitations in the review results so the user understands the importance of the what-if step.\n\n### Step 6: Report Results\n\n```markdown\n## Bicep Code Review Results\n\n**Compilation Result**: [PASS/WARNING N items]\n**Checklist**: ✅ Passed X items / ⚠️ Warnings X items\n**Hardcoding Check**: [PASS / N violations]\n**Auto-fixed**: X items\n\n### Compilation Warnings (Remaining)\n- [Warning content — including reason why it cannot be fixed]\n\n### Auto-fix Details\n- [File:line number] Before → After (reason)\n\n### Hardcoding Violations (If Any)\n- [File:line number] [Violation details] → [Fix method]\n\n**Conclusion**: [Ready for deployment / Manual review required]\n```\n\n### Step 7: Phase 4 Transition — Reassurance Message Required\n\nWhen asking whether to proceed to Phase 4 after passing code review, **always include a message to reassure the user**.\nUsers may feel uneasy about the word \"deployment\", so clearly communicate that what-if is a safe validation step.\n\n```\nask_user({\n  question: \"Code review passed! Ready to proceed to the next step?\\n\\n⚡ This does NOT deploy immediately:\\n  1️⃣ What-if validation — Simulates what will be created (not a deployment, safe)\\n  2️⃣ Preview diagram — Review the architecture to be deployed as a diagram\\n  3️⃣ Final confirmation — Actual deployment only after you review the diagram and approve\\n\\nNothing will be deployed without your approval.\",\n  choices: [\n    \"Proceed to next step (what-if validation + preview diagram) (Recommended)\",\n    \"Just give me the code, I'll deploy later\"\n  ]\n})\n```\n\n**Key points:**\n- Always state \"This does NOT deploy immediately\"\n- Explain the 3-step process: what-if → preview diagram → final confirmation\n- Reassure with \"Nothing will be deployed without your approval\"\n","references/phase0-scanner.md":"# Phase 0: Existing Resource Scanner\n\nThis file contains the detailed instructions for Phase 0. When the user requests analysis of existing Azure resources (Path B), read and follow this file.\n\nScan results are visualized as an architecture diagram, and subsequent natural-language modification requests from the user are routed to Phase 1.\n\n\u003e **🚨 Output Storage Path Rule**: All outputs (scan JSON, diagram HTML, Bicep code) must be saved in **a project folder under the current working directory (cwd)**. NEVER save them inside `~/.copilot/session-state/`. The session-state directory is a temporary space and may be deleted when the session ends.\n\n---\n\n## Step 1: Azure Login + Scan Scope Selection\n\n### 1-A: Verify Azure Login\n\n```powershell\naz account show 2\u003e\u00261\n```\n\n- If logged in → Proceed to Step 1-B\n- If not logged in → Ask the user to run `az login`\n\n### 1-B: Subscription Selection (Multiple Selection Supported)\n\n```powershell\naz account list --output json\n```\n\nPresent the subscription list as `ask_user` choices. **Multiple subscriptions can be selected:**\n```\nask_user({\n  question: \"Please select the Azure subscription(s) to analyze. (You can add more one at a time for multiple selections)\",\n  choices: [\n    \"sub-002 (Current default subscription) (Recommended)\",\n    \"sub-001\",\n    \"Analyze all subscriptions above\"\n  ]\n})\n```\n\n- Single subscription selected → Scan only that subscription\n- \"Analyze all\" selected → Scan all subscriptions\n- If the user wants additional subscriptions → Use ask_user again to add more\n\n### 1-C: Scan Scope Selection (Multiple RG Selection Supported)\n\n```\nask_user({\n  question: \"What scope of Azure resources would you like to analyze?\",\n  choices: [\n    \"Specify a particular resource group (Recommended)\",\n    \"Select multiple resource groups\",\n    \"All resource groups in the current subscription\"\n  ]\n})\n```\n\n- **Specific RG** → Select from the RG list or enter manually\n- **Multiple RGs** → Repeat ask_user to add RGs one at a time. Stop when the user says \"that's enough.\"\n  Alternatively, the user can enter multiple RGs separated by commas (e.g., `rg-prod, rg-dev, rg-network`)\n- **Entire subscription** → `az group list` → Scan all RGs (warn if there are many resources that it may take time)\n\n**Combining multiple subscriptions + multiple RGs is supported:**\n- rg-prod from subscription A + rg-network from subscription B → Scan both and display in a single diagram\n\n---\n\n## Diagram Hierarchy — Displaying Multiple Subscriptions/RGs\n\n**Single subscription + single RG**: Same as before (VNet boundary only)\n**Multiple RGs (same subscription)**: Dashed boundary per RG\n**Multiple subscriptions**: Two-level boundary of Subscription \u003e RG\n\nPass hierarchy information in the diagram JSON:\n\n**Add `subscription` and `resourceGroup` fields to the services JSON:**\n```json\n{\n  \"id\": \"foundry\",\n  \"name\": \"foundry-xxx\",\n  \"type\": \"ai_foundry\",\n  \"subscription\": \"sub-002\",\n  \"resourceGroup\": \"rg-prod\",\n  \"details\": [...]\n}\n```\n\n**Pass hierarchy information via the `--hierarchy` parameter:**\n```\n--hierarchy '[{\"subscription\":\"sub-002\",\"resourceGroups\":[\"rg-prod\",\"rg-dev\"]},{\"subscription\":\"sub-001\",\"resourceGroups\":[\"rg-network\"]}]'\n```\n\nBased on this information, the diagram script will:\n- Multiple RGs → Represent each RG as a cluster with a dashed boundary (label: RG name)\n- Multiple subscriptions → Nest RG boundaries inside larger subscription boundaries\n- VNet boundaries are displayed inside the RG to which the VNet belongs\n\n---\n\n## Step 2: Resource Scan\n\n**🚨 az CLI Output Principles:**\n- az CLI output must **always be saved to a file** and then read with `view`. Direct terminal output may be truncated.\n- Bundle **no more than 3 az commands** per PowerShell call. Bundling too many may cause timeouts.\n- Use `--query` JMESPath to extract only the required fields and reduce output size.\n\n```powershell\n# ✅ Correct approach — Save to file then read\naz resource list -g \"\u003cRG\u003e\" --query \"[].{name:name,type:type,kind:kind,location:location}\" -o json | Set-Content -Path \"$outDir/resources.json\"\n\n# ❌ Wrong approach — Direct terminal output (may be truncated)\naz resource list -g \"\u003cRG\u003e\" -o json\n```\n\n### 2-A: List All Resources + Display to User\n\n```powershell\n$outDir = \"\u003cproject-name\u003e/azure-scan\"\nNew-Item -ItemType Directory -Path $outDir -Force | Out-Null\n\n# Step 1: Basic resource list (name, type, kind, location)\naz resource list -g \"\u003cRG\u003e\" --query \"[].{name:name,type:type,kind:kind,location:location,id:id}\" -o json | Set-Content \"$outDir/resources.json\"\n```\n\n**🚨 Immediately after reading resources.json, you MUST display the full resource list table to the user:**\n\n```\n📋 rg-\u003cRG\u003e Resource List (N resources)\n\n┌─────────────────────────┬──────────────────────────────────────────────┬─────────────────┐\n│ Name                    │ Type                                         │ Location        │\n├─────────────────────────┼──────────────────────────────────────────────┼─────────────────┤\n│ my-storage              │ Microsoft.Storage/storageAccounts             │ koreacentral    │\n│ my-keyvault             │ Microsoft.KeyVault/vaults                    │ koreacentral    │\n│ ...                     │ ...                                          │ ...             │\n└─────────────────────────┴──────────────────────────────────────────────┴─────────────────┘\n\n⏳ Retrieving detailed information...\n```\n\nDisplay this table **first** before proceeding to detailed queries. Do not make the user wait without knowing what resources exist.\n\n### 2-B: Dynamic Detailed Query — Based on resources.json\n\n**Dynamically determine detailed query commands based on the resource types found in resources.json.**\n\nDo not use a hardcoded command list. Only execute commands for types that exist in resources.json, selected from the mapping table below.\n\n**Type → Detailed Query Command Mapping:**\n\n| Type in resources.json | Detailed Query Command | Output File |\n|---|---|---|\n| `Microsoft.Network/virtualNetworks` | `az network vnet list -g \"\u003cRG\u003e\" --query \"[].{name:name,addressSpace:addressSpace.addressPrefixes,subnets:subnets[].{name:name,prefix:addressPrefix,pePolicy:privateEndpointNetworkPolicies}}\" -o json` | `vnets.json` |\n| `Microsoft.Network/privateEndpoints` | `az network private-endpoint list -g \"\u003cRG\u003e\" --query \"[].{name:name,subnetId:subnet.id,targetId:privateLinkServiceConnections[0].privateLinkServiceId,groupIds:privateLinkServiceConnections[0].groupIds,state:provisioningState}\" -o json` | `pe.json` |\n| `Microsoft.Network/networkSecurityGroups` | `az network nsg list -g \"\u003cRG\u003e\" --query \"[].{name:name,location:location,subnets:subnets[].id,nics:networkInterfaces[].id}\" -o json` | `nsg.json` |\n| `Microsoft.CognitiveServices/accounts` | `az cognitiveservices account list -g \"\u003cRG\u003e\" --query \"[].{name:name,kind:kind,sku:sku.name,endpoint:properties.endpoint,publicAccess:properties.publicNetworkAccess,location:location}\" -o json` | `cognitive.json` |\n| `Microsoft.Search/searchServices` | `az search service list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,publicAccess:properties.publicNetworkAccess,semanticSearch:properties.semanticSearch,location:location}\" -o json 2\u003e$null` | `search.json` |\n| `Microsoft.Compute/virtualMachines` | `az vm list -g \"\u003cRG\u003e\" --query \"[].{name:name,size:hardwareProfile.vmSize,os:storageProfile.osDisk.osType,location:location,nicIds:networkProfile.networkInterfaces[].id}\" -o json` | `vms.json` |\n| `Microsoft.Storage/storageAccounts` | `az storage account list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,kind:kind,hns:properties.isHnsEnabled,publicAccess:properties.publicNetworkAccess,location:location}\" -o json` | `storage.json` |\n| `Microsoft.KeyVault/vaults` | `az keyvault list -g \"\u003cRG\u003e\" --query \"[].{name:name,location:location}\" -o json 2\u003e$null` | `keyvault.json` |\n| `Microsoft.ContainerService/managedClusters` | `az aks list -g \"\u003cRG\u003e\" --query \"[].{name:name,kubernetesVersion:kubernetesVersion,sku:sku,agentPoolProfiles:agentPoolProfiles[].{name:name,count:count,vmSize:vmSize},networkProfile:networkProfile.networkPlugin,location:location}\" -o json` | `aks.json` |\n| `Microsoft.Web/sites` | `az webapp list -g \"\u003cRG\u003e\" --query \"[].{name:name,kind:kind,sku:appServicePlan,state:state,defaultHostName:defaultHostName,httpsOnly:httpsOnly,location:location}\" -o json` | `webapps.json` |\n| `Microsoft.Web/serverFarms` | `az appservice plan list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,tier:sku.tier,kind:kind,location:location}\" -o json` | `appservice-plans.json` |\n| `Microsoft.DocumentDB/databaseAccounts` | `az cosmosdb list -g \"\u003cRG\u003e\" --query \"[].{name:name,kind:kind,databaseAccountOfferType:databaseAccountOfferType,locations:locations[].locationName,publicAccess:publicNetworkAccess}\" -o json` | `cosmosdb.json` |\n| `Microsoft.Sql/servers` | `az sql server list -g \"\u003cRG\u003e\" --query \"[].{name:name,fullyQualifiedDomainName:fullyQualifiedDomainName,publicAccess:publicNetworkAccess,location:location}\" -o json` | `sql-servers.json` |\n| `Microsoft.Databricks/workspaces` | `az databricks workspace list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,url:workspaceUrl,publicAccess:parameters.enableNoPublicIp.value,location:location}\" -o json 2\u003e$null` | `databricks.json` |\n| `Microsoft.Synapse/workspaces` | `az synapse workspace list -g \"\u003cRG\u003e\" --query \"[].{name:name,sqlAdminLogin:sqlAdministratorLogin,publicAccess:publicNetworkAccess,location:location}\" -o json 2\u003e$null` | `synapse.json` |\n| `Microsoft.DataFactory/factories` | `az datafactory list -g \"\u003cRG\u003e\" --query \"[].{name:name,publicAccess:publicNetworkAccess,location:location}\" -o json 2\u003e$null` | `adf.json` |\n| `Microsoft.EventHub/namespaces` | `az eventhubs namespace list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,location:location}\" -o json` | `eventhub.json` |\n| `Microsoft.Cache/redis` | `az redis list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,port:port,sslPort:sslPort,publicAccess:publicNetworkAccess,location:location}\" -o json` | `redis.json` |\n| `Microsoft.ContainerRegistry/registries` | `az acr list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,adminUserEnabled:adminUserEnabled,publicAccess:publicNetworkAccess,location:location}\" -o json` | `acr.json` |\n| `Microsoft.MachineLearningServices/workspaces` | `az resource show --ids \"\u003cID\u003e\" --query \"{name:name,sku:sku,kind:kind,location:location,publicAccess:properties.publicNetworkAccess,hbiWorkspace:properties.hbiWorkspace,managedNetwork:properties.managedNetwork.isolationMode}\" -o json` | `mlworkspace.json` |\n| `Microsoft.Insights/components` | `az monitor app-insights component show -g \"\u003cRG\u003e\" --app \"\u003cNAME\u003e\" --query \"{name:name,kind:kind,instrumentationKey:instrumentationKey,workspaceResourceId:workspaceResourceId,location:location}\" -o json 2\u003e$null` | `appinsights-\u003cNAME\u003e.json` |\n| `Microsoft.OperationalInsights/workspaces` | `az monitor log-analytics workspace show -g \"\u003cRG\u003e\" -n \"\u003cNAME\u003e\" --query \"{name:name,sku:sku.name,retentionInDays:retentionInDays,location:location}\" -o json` | `log-analytics-\u003cNAME\u003e.json` |\n| `Microsoft.Network/applicationGateways` | `az network application-gateway list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku,location:location}\" -o json` | `appgateway.json` |\n| `Microsoft.Cdn/profiles` / `Microsoft.Network/frontDoors` | `az afd profile list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,location:location}\" -o json 2\u003e$null` | `frontdoor.json` |\n| `Microsoft.Network/azureFirewalls` | `az network firewall list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku,threatIntelMode:threatIntelMode,location:location}\" -o json` | `firewall.json` |\n| `Microsoft.Network/bastionHosts` | `az network bastion list -g \"\u003cRG\u003e\" --query \"[].{name:name,sku:sku.name,location:location}\" -o json` | `bastion.json` |\n\n**Dynamic Query Process:**\n\n1. Read `resources.json`\n2. Extract the distinct values of the `type` field\n3. Execute **only the commands for matching types** from the mapping table above (skip types not present)\n4. If a type not in the mapping table is found → Use generic query: `az resource show --ids \"\u003cID\u003e\" --query \"{name:name,sku:sku,kind:kind,location:location,properties:properties}\" -o json`\n5. Execute commands in batches of 2-3 (do not run all at once)\n\n### 2-C: Model Deployment Query (When Cognitive Services Exist)\n\n```powershell\n# Query model deployments for each Cognitive Services resource\naz cognitiveservices account deployment list --name \"\u003cNAME\u003e\" -g \"\u003cRG\u003e\" --query \"[].{name:name,model:properties.model.name,version:properties.model.version,sku:sku.name}\" -o json | Set-Content \"$outDir/\u003cNAME\u003e-deployments.json\"\n```\n\n### 2-D: NIC + Public IP Query (When VMs Exist)\n\n```powershell\naz network nic list -g \"\u003cRG\u003e\" --query \"[].{name:name,subnetId:ipConfigurations[0].subnet.id,privateIp:ipConfigurations[0].privateIPAddress,publicIpId:ipConfigurations[0].publicIPAddress.id}\" -o json | Set-Content \"$outDir/nics.json\"\naz network public-ip list -g \"\u003cRG\u003e\" --query \"[].{name:name,ip:ipAddress,sku:sku.name}\" -o json | Set-Content \"$outDir/public-ips.json\"\n```\n\nFrom the VNet:\n- `addressSpace.addressPrefixes` → CIDR\n- `subnets[].name`, `subnets[].addressPrefix` → Subnet information\n- `subnets[].privateEndpointNetworkPolicies` → PE policies\n\n---\n\n## Step 3: Inferring Relationships Between Resources\n\nAutomatically infer **relationships (connections)** between scanned resources to construct the connections JSON for the diagram.\n\n### Relationship Inference Rules\n\n**🚨 If there are insufficient connection lines, the diagram becomes meaningless. Infer as many relationships as possible.**\n\n#### Confirmed Inference (Directly verifiable from resource IDs/properties)\n\n| Relationship Type | Inference Method | connection type |\n|---|---|---|\n| PE → Service | Extract service ID from PE's `privateLinkServiceId` | `private` |\n| PE → VNet | Extract VNet from PE's `subnet.id` | (Represented as VNet boundary) |\n| Foundry → Project | Parent resource of `accounts/projects` | `api` |\n| VM → NIC → Subnet | Infer VNet/Subnet from NIC's `subnet.id` | (VNet boundary) |\n| NSG → Subnet | Check connected subnets from NSG's `subnets[].id` | `network` |\n| NSG → NIC | Check connected VMs from NSG's `networkInterfaces[].id` | `network` |\n| NIC → Public IP | Check PIP from NIC's `publicIPAddress.id` | (Included in details) |\n| Databricks → VNet | Workspace's VNet injection configuration | (VNet boundary) |\n\n#### Reasonable Inference (Common patterns between services within the same RG)\n\n| Relationship Type | Inference Condition | connection type |\n|---|---|---|\n| Foundry → AI Search | Both exist in the same RG → Infer RAG connection | `api` (label: \"RAG Search\") |\n| Foundry → Storage | Both exist in the same RG → Infer data connection | `data` (label: \"Data\") |\n| AI Search → Storage | Both exist in the same RG → Infer indexing connection | `data` (label: \"Indexing\") |\n| Service → Key Vault | Key Vault exists in the same RG → Infer secret management | `security` (label: \"Secrets\") |\n| VM → Foundry/Search | VM + AI services exist in the same RG → Infer API calls | `api` (label: \"API\") |\n| DI → Foundry | Document Intelligence + Foundry exist in the same RG → Infer OCR/extraction connection | `api` (label: \"OCR/Extract\") |\n| ADF → Storage | ADF + Storage exist in the same RG → Infer data pipeline | `data` (label: \"Pipeline\") |\n| ADF → SQL | ADF + SQL exist in the same RG → Infer data source | `data` (label: \"Source\") |\n| Databricks → Storage | Both exist in the same RG → Infer data lake connection | `data` (label: \"Data Lake\") |\n\n#### User Confirmation After Inference\n\nShow the inferred connection list to the user and request confirmation:\n```\n\u003e **⏳ Relationships between resources have been inferred** — Please verify if the following are correct.\n\nInferred connections:\n- Foundry → AI Search (RAG Search)\n- Foundry → Storage (Data)\n- VM → Foundry (API Call)\n- Document Intelligence → Foundry (OCR/Extract)\n\nDoes this look correct? Let me know if you'd like to add or remove any connections.\n```\n\n#### Relationships That Cannot Be Inferred\n\nThere may be connections that cannot be inferred using the rules above. The user can freely add additional connections.\n\n### Model Deployment Query (When Foundry Resources Exist)\n\n```powershell\naz cognitiveservices account deployment list --name \"\u003cFOUNDRY_NAME\u003e\" -g \"\u003cRG\u003e\" --query \"[].{name:name,model:properties.model.name,version:properties.model.version,sku:sku.name}\" -o json\n```\n\nAdd each deployment's model name, version, and SKU to the Foundry node's details.\n\n---\n\n## Step 4: services/connections JSON Conversion\n\nConvert scan results into the input format for the built-in diagram engine.\n\n### Resource Type → Diagram type Mapping\n\n| Azure Resource Type | Diagram type |\n|---|---|\n| `Microsoft.CognitiveServices/accounts` (kind: AIServices) | `ai_foundry` |\n| `Microsoft.CognitiveServices/accounts` (kind: OpenAI) | `openai` |\n| `Microsoft.CognitiveServices/accounts` (kind: FormRecognizer) | `document_intelligence` |\n| `Microsoft.CognitiveServices/accounts` (kind: TextAnalytics, etc.) | `ai_foundry` (default) |\n| `Microsoft.CognitiveServices/accounts/projects` | `ai_foundry` |\n| `Microsoft.Search/searchServices` | `search` |\n| `Microsoft.Storage/storageAccounts` | `storage` |\n| `Microsoft.KeyVault/vaults` | `keyvault` |\n| `Microsoft.Databricks/workspaces` | `databricks` |\n| `Microsoft.Sql/servers` | `sql_server` |\n| `Microsoft.Sql/servers/databases` | `sql_database` |\n| `Microsoft.DocumentDB/databaseAccounts` | `cosmos_db` |\n| `Microsoft.Web/sites` | `app_service` |\n| `Microsoft.ContainerService/managedClusters` | `aks` |\n| `Microsoft.Web/sites` (kind: functionapp) | `function_app` |\n| `Microsoft.Synapse/workspaces` | `synapse` |\n| `Microsoft.Fabric/capacities` | `fabric` |\n| `Microsoft.DataFactory/factories` | `adf` |\n| `Microsoft.Compute/virtualMachines` | `vm` |\n| `Microsoft.Network/privateEndpoints` | `pe` |\n| `Microsoft.Network/virtualNetworks` | (Represented as VNet boundary — not included in services) |\n| `Microsoft.Network/networkSecurityGroups` | `nsg` |\n| `Microsoft.Network/bastionHosts` | `bastion` |\n| `Microsoft.OperationalInsights/workspaces` | `log_analytics` |\n| `Microsoft.Insights/components` | `app_insights` |\n| Other | `default` |\n\n### services JSON Construction Rules\n\n```json\n{\n  \"id\": \"resource name (lowercase, special characters removed)\",\n  \"name\": \"actual resource name\",\n  \"type\": \"determined from the mapping table above\",\n  \"sku\": \"actual SKU (if available)\",\n  \"private\": true/false,  // true if a PE is connected\n  \"details\": [\"property1\", \"property2\", ...]\n}\n```\n\n**Information to include in details:**\n- Endpoint URL\n- SKU/tier details\n- kind (AIServices, OpenAI, etc.)\n- Model deployment list (Foundry)\n- Key properties (isHnsEnabled, semanticSearch, etc.)\n- Region\n\n### VNet Information → `--vnet-info` Parameter\n\nIf a VNet is found, display it in the boundary label via `--vnet-info`:\n```\n--vnet-info \"10.0.0.0/16 | pe-subnet: 10.0.1.0/24 | \u003cregion\u003e\"\n```\n\n### PE Node Generation\n\nIf PEs are found, add each PE as a separate node and connect it to the corresponding service with a `private` type:\n```json\n{\"id\": \"pe_\u003cserviceId\u003e\", \"name\": \"PE: \u003cserviceName\u003e\", \"type\": \"pe\", \"details\": [\"groupId: \u003cgroupId\u003e\", \"\u003cstatus\u003e\"]}\n```\n\n---\n\n## Step 5: Diagram Generation + Presentation to User\n\nDiagram filename: `\u003cproject-name\u003e/00_arch_current.html`\n\nUse the scanned RG name as the default project name:\n```\nask_user({\n  question: \"Please choose a project name. (This will be the folder name for scan results)\",\n  choices: [\"\u003cRG-name\u003e\", \"azure-analysis\"]\n})\n```\n\nAfter generating the diagram, report:\n```\n## Current Azure Architecture\n\n[Interactive Diagram — 00_arch_current.html]\n\nScanned Resources (N total):\n[Summary table by resource type]\n\nWhat would you like to change here?\n- 🔧 Performance improvement (\"it's slow\", \"increase throughput\")\n- 💰 Cost optimization (\"reduce costs\", \"make it cheaper\")\n- 🔒 Security hardening (\"add PE\", \"block public access\")\n- 🌐 Network changes (\"separate VNet\", \"add Bastion\")\n- ➕ Add/remove resources (\"add a VM\", \"delete this\")\n- 📊 Monitoring (\"set up logs\", \"add alerts\")\n- 🤔 Diagnostics (\"is this architecture OK?\", \"what's wrong?\")\n- Or just take the diagram and stop here\n```\n\n---\n\n## Step 6: Modification Conversation → Transition to Phase 1\n\nWhen the user requests modifications, transition to Phase 1 (phase1-advisor.md).\nThis is the **Path B entry point**, using the existing scan results as the baseline.\n\n### Natural Language Modification Request Handling — Clarifying Question Patterns\n\nAsk clarifying questions to make the user's vague requests more specific:\n\n**🔧 Performance**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"It's slow\" / \"Response takes too long\" | \"Which service is slow? Should we upgrade the SKU or change the region?\" |\n| \"I want to increase throughput\" | \"Which service's throughput should we increase? Scale out? Increase DTU/RU?\" |\n| \"AI Search indexing is slow\" | \"Should we add partitions? Upgrade the SKU to S2?\" |\n\n**💰 Cost**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"I want to reduce costs\" | \"Which service's cost should we reduce? SKU downgrade? Clean up unused resources?\" |\n| \"How much does this cost?\" | Look up pricing info from MS Docs and provide estimated cost based on current SKUs |\n| \"It's a dev environment, so make it cheap\" | \"Should we switch to Free/Basic tiers? Which services?\" |\n\n**🔒 Security**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"Harden the security\" | \"Should we add PEs to services that don't have them? Check RBAC? Disable publicNetworkAccess?\" |\n| \"Block public access\" | \"Should we apply PE + publicNetworkAccess: Disabled to all services?\" |\n| \"Manage the keys\" | \"Should we add Key Vault and connect it with Managed Identity?\" |\n\n**🌐 Network**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"Add PE\" | \"To which service? Should we add them to all services at once?\" |\n| \"Separate the VNet\" | \"Which subnets should we separate? Should we also add NSGs?\" |\n| \"Add Bastion\" | \"Adding Azure Bastion for VM access. Please specify the subnet CIDR.\" |\n\n**➕ Add/Remove Resources**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"Add a VM\" | \"How many? What SKU? Same VNet? What OS?\" |\n| \"Add Fabric\" | \"What SKU? What's the admin email?\" |\n| \"Delete this\" | \"Are you sure you want to remove [resource name]? Connected PEs will also be removed.\" |\n\n**📊 Monitoring/Operations**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"I want to see logs\" | \"Should we add a Log Analytics Workspace and connect Diagnostic Settings?\" |\n| \"Set up alerts\" | \"For which metrics? CPU? Error rate? Response time?\" |\n| \"Attach Application Insights\" | \"To which service? App Service? Function App?\" |\n\n**🔄 Migration/Changes**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"Change the region\" | \"To which region? I'll verify that all services are available in that region.\" |\n| \"Switch SQL to Cosmos\" | \"What Cosmos DB API type? (SQL/MongoDB/Cassandra) I can also provide a data migration guide.\" |\n| \"Switch Foundry to Hub\" | \"Hub is suitable only when ML training/open-source models are needed. Let me verify the use case.\" |\n\n**🤔 Diagnostics/Questions**\n\n| User Request | Clarifying Question Example |\n|---|---|\n| \"What's wrong?\" | Analyze current configuration (publicNetworkAccess open, PE not connected, inappropriate SKU, etc.) and suggest improvements |\n| \"Is this architecture OK?\" | Review against the Well-Architected Framework (security, reliability, performance, cost, operations) |\n| \"Is the PE connected properly?\" | Check connection status with `az network private-endpoint show` and report |\n| \"Just give me the diagram\" | Do not transition to Phase 1; provide the 00_arch_current.html path and finish |\n\nOnce modifications are finalized:\n1. Apply Phase 1's Delta Confirmation Rule\n2. Fact-check (cross-verify with MS Docs)\n3. Generate updated diagram (01_arch_diagram_draft.html)\n4. User confirmation → Proceed to Phases 2–4\n\n---\n\n## Scan Performance Optimization\n\n- If there are 50+ resources, warn the user: \"There are many resources, so the scan may take some time.\"\n- Run `az resource list` first to determine the resource count, then proceed with detailed queries\n- Query key services first (Foundry, Search, Storage, KeyVault, VNet, PE), then collect only basic information for the rest via `az resource show`\n- Keep the user informed of progress:\n  \u003e **⏳ Scanning resources** — M of N resources completed\n\n---\n\n## Handling Unsupported Resources\n\nFor resource types not in the diagram type mapping:\n- Display with `default` type (question mark icon)\n- Include the resource name and type in details\n- Show to the user, but do not attempt relationship inference\n","references/phase1-advisor.md":"# Phase 1: Architecture Advisor\n\nThis file contains the detailed instructions for Phase 1. When entering Phase 1 from SKILL.md, read and follow this file.\nUsed in both Path A (new design) and Path B (modification after Phase 0 scan).\n\n---\n\n## When Entering from Path B (After Existing Resource Analysis)\n\nThe current architecture diagram (00_arch_current.html) scanned in Phase 0 already exists.\nIn this case, skip the project name/service list confirmation in 1-1 and enter the modification conversation directly:\n\n1. \"What would you like to change here?\" — User's natural language request\n2. Apply Delta Confirmation Rule — Confirm undecided required fields for the changes\n3. Fact check — Cross-verify with MS Docs\n4. Generate updated diagram (01_arch_diagram_draft.html)\n5. Proceed to Phase 2 after confirmation\n\n---\n\n**Goal of this Phase**: Accurately identify what the user wants and finalize the architecture together.\n\n### 1-1. Diagram Preparation — Gathering Required Information\n\nBefore drawing the diagram, ask the user questions until all items below are confirmed.\n**Generate the diagram only after all items are confirmed.**\n\n**First, confirm the project name:**\n\nProvide a default value as a choice via `ask_user`. If the user just presses Enter, the default is applied; they can also type a custom name.\nThe default is inferred from the user's request (e.g., RAG chatbot → `rag-chatbot`, data platform → `data-platform`).\n\n```\nask_user({\n  question: \"Please choose a project name. It will be used for the Bicep folder name, diagram path, and deployment name.\",\n  choices: [\"\u003cinferred-default\u003e\", \"azure-project\"]\n})\n```\nThe project name is used for the Bicep output folder name, diagram save path, deployment name, etc.\n\n**🔹 Parallel Preload Along with Project Name Question (Required):**\n\nWhen asking the project name via `ask_user`, there is idle time while waiting for the user to respond.\nUtilize this time to **preload information needed for subsequent questions and Bicep generation in parallel**.\n\n**Tools to call simultaneously with ask_user:**\n\n```\n// Call ask_user + the tools below simultaneously in a single response\n[1] ask_user — Project name question\n\n[2] view — Load reference files (pre-acquire Stable information)\n    - references/service-gotchas.md\n    - references/ai-data.md\n    - references/azure-dynamic-sources.md\n    - references/architecture-guidance-sources.md\n\n[3] web_fetch — Pre-fetch architecture guidance (when workload type is identified)\n    - Up to 2 targeted fetches based on decision rules in architecture-guidance-sources.md\n\n[4] web_fetch — Fetch MS Docs for services mentioned by the user (pre-acquire Dynamic information)\n    - e.g., Foundry → API version, model availability page\n    - e.g., AI Search → SKU list page\n    - Use URL patterns from azure-dynamic-sources.md\n```\n\n**Benefits**: While the user types the project name, all information is loaded,\nso SKU/region questions can be presented with accurate choices immediately after the project name is confirmed.\nWait time is significantly reduced compared to sequential execution.\n\n**Notes:**\n- Preload targets are only information independent of the project name (nothing depends on the name)\n- web_fetch is performed only for services mentioned in the user's initial request (no guessing)\n- Azure CLI check (`az account show`) is NOT done at this point — preload at architecture finalization\n\n**🔹 Utilizing Architecture Guidance (Adjusting Question Depth):**\n\nExtract **design decision points** from the architecture guidance documents fetched during preload,\nand naturally incorporate them into subsequent user questions.\n\n**Purpose**: Not just spec questions like SKU/region,\nbut reflecting **design decision points** recommended by official architecture guidance into the questions.\n\n**Example — When \"RAG chatbot\" is requested:**\n- Fetch Baseline Foundry Chat Architecture (A6)\n- Extract recommended design decision points from the document:\n  → Network isolation level (full private vs hybrid?)\n  → Authentication method (managed identity vs API key?)\n  → Data ingestion strategy (push vs pull indexing?)\n  → Monitoring scope (Application Insights needed?)\n- Naturally include these points in user questions\n\n**Notes:**\n- What is extracted from architecture guidance is **\"points to ask about\"**, not \"answers\"\n- Deployment specs like SKU/API version/region are still determined only via `azure-dynamic-sources.md`\n- Fetch budget: maximum 2 documents. No full traversal\n\n**Required confirmation items:**\n- [ ] Project name (default: `azure-project`)\n- [ ] Service list (which Azure services to use)\n- [ ] SKU/tier for each service\n- [ ] Networking method (Private Endpoint usage)\n- [ ] Deployment location (region)\n\n**Questioning principles:**\n- Do not ask again for information the user has already mentioned\n- Do not ask about detailed implementation specifics not directly represented in the diagram (indexing method, query volume, etc.)\n- Do not ask too many questions at once; ask only key undecided items concisely\n- For items with obvious defaults (e.g., PE enabled), assume and just confirm. However, location MUST always be confirmed with the user\n- **When asking about SKUs, models, or service options, show ALL available choices verified from MS Docs, and provide the MS Docs URL as well.** This allows the user to reference and make their own judgment. Do not show only partial options or arbitrarily filter them out\n\n**🔹 VM/Resource SKU Selection — Region Availability Pre-check Required:**\n\n**Before** asking the user about VM or other resource SKUs, you MUST first query which SKUs are actually available in the target region.\nIf a SKU is blocked due to capacity restrictions in a specific region, the deployment will fail.\n\n**VM SKU verification method:**\n```powershell\n# Query only VM SKUs available without restrictions in the target region\naz vm list-skus --location \"\u003cLOCATION\u003e\" --size Standard_D2 --resource-type virtualMachines `\n  --query \"[?restrictions==``[]``].name\" -o tsv\n```\n\n**Principles:**\n- Do not include unverified SKUs in the choices\n- Do not recommend \"commonly used SKUs\" from memory — MUST verify via az cli or MS Docs\n- Include only verified SKUs in `ask_user` choices\n- Even for user-provided SKUs, verify availability before proceeding\n\n**This principle applies equally not just to VMs, but to ALL resources subject to capacity restrictions (Fabric Capacity, etc.).**\n\n**🔹 Service Option Exploration Principle — \"Listing from Memory\" is Prohibited:**\n\nWhen the user asks about a service category (\"What Spark options are there?\", \"What are the message queue options?\"), or when you need to explore services for a specific capability:\n\n**NEVER do this:**\n- Directly fetch URLs for only 2-3 services from your memory and list them\n- State definitively \"In Azure, X has A and B\"\n\n**MUST do this:**\n1. **Explore the full category via web_search** — Search at the category level like `\"Azure managed Spark options site:learn.microsoft.com\"` to first discover what services exist\n2. **Cross-check with v1 scope** — Regardless of search results, check whether v1 scope services (Foundry, Fabric, AI Search, ADLS Gen2, etc.) fall under the relevant category. e.g.: \"Spark\" → Microsoft Fabric's Data Engineering workload also provides Spark\n3. **Targeted fetch of discovered options** — Fetch MS Docs for the services found via search to collect accurate comparison information\n4. **Present all options to the user** — Present all discovered options in a comprehensive comparison without omitting any\n\n**Example — When asked \"What Spark instances are available?\":**\n```\nWrong approach: Fetch only Databricks URL + Synapse URL → Compare only 2\nCorrect approach: web_search(\"Azure managed Spark options\") → Discover Databricks, Synapse, Fabric Spark, HDInsight\n            → v1 scope check: Fabric is v1 scope and provides Spark → MUST include\n            → Targeted fetch of each service's MS Docs → Present full comparison table\n```\n\nThis principle applies not only to service category exploration, but to all situations where the user requests \"alternatives\", \"other options\", \"comparison\", etc.\n\n**🔹 ask_user Tool — Mandatory Usage:**\n\nFor questions with choices, you MUST use the `ask_user` tool. It allows users to select with arrow keys for convenience, and they can also type a custom input.\n\n**ask_user usage rules:**\n- Questions with 2 or more choices **MUST** use ask_user (do not list them as text)\n- **`choices` MUST be passed as a string array (`[\"A\", \"B\"]`)** — passing as a string (`\"A, B\"`) will cause an error\n- If there is a recommended option, place it first and append `(Recommended)` at the end\n- Include reference information in choices — e.g., `\"Standard S1 - Recommended for production. Ref: https://...\"`\n- **Only 1 question per call** — if multiple items need to be asked, call ask_user sequentially for each\n- Choices are limited to a maximum of 4. If there are 5 or more, include only the 3-4 most common ones (users can also type a custom input)\n- If multiple selections are needed, split them into separate questions\n\n**Items requiring ask_user:**\n- Deployment location (region) selection\n- SKU/tier selection\n- Model selection (chat model, embedding model, etc.)\n- Networking method selection\n- Subscription selection (Phase 1 Step 2)\n- Resource group selection (Phase 1 Step 3)\n- Any other question requiring a user choice\n\n**Usage examples:**\n```\n// Project name is free-form input so ask_user is not used (ask as text)\n// SKU, region, etc. with defined choices use ask_user:\n\n// 1. SKU question\nask_user({\n  question: \"Please select the SKU for AI Search. Ref: https://learn.microsoft.com/en-us/azure/search/search-sku-tier\",\n  choices: [\n    \"Standard S1 - Recommended for production (Recommended)\",\n    \"Basic - For dev/test, up to 15 indexes\",\n    \"Standard S2 - High-traffic production\",\n    \"Free - Free trial, 50MB storage\"\n  ]\n})\n\n// 2. Region question (separate call — only 1 question per call)\nask_user({\n  question: \"Please select the Azure region for deployment. Ref: https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models\",\n  choices: [\n    \"Korea Central - Korea region, supports most services (Recommended)\",\n    \"East US - US East, supports all AI models\",\n    \"Japan East - Japan East, close to Korea\"\n  ]\n})\n```\n\n\u003e **Note**: The SKU and region values in the examples above are for illustration only. When actually asking, dynamically compose choices based on the latest information by querying MS Docs via web_fetch. Do not hardcode.\n\n**Example — When user input is insufficient:**\n```\nUser: \"I want to build a RAG chatbot. Using a GPT model in Foundry and AI Search.\"\n\n→ Confirmed: Microsoft Foundry, Azure AI Search\n→ Still undecided: Project name, specific model name, embedding model, networking (PE?), SKU, deployment location\n\nThe agent first confirms the project name via ask_user (default: rag-chatbot).\nThen provides choices for each undecided item via the ask_user tool.\nInclude MS Docs URLs in the choices so the user can reference them directly.\n```\n\n**🚨🚨🚨 [HARD GATE] Spec Collection Complete → Diagram Generation Required 🚨🚨🚨**\n\n**Immediately after all confirmed items are filled in, you MUST perform the following steps IN ORDER. Skipping any step means Phase 1 is incomplete.**\n\n1. Compose **services JSON + connections JSON** based on the confirmed service list\n2. Use the built-in diagram engine to generate **`\u003cproject-name\u003e/01_arch_diagram_draft.html`**\n3. Automatically open it in the browser via `Start-Process`\n4. Show the diagram to the user in the **report format** below — this MUST include a **detailed configuration table**\n5. Ask the user: **\"Would you like to change or add anything?\"**\n6. If the user has no changes → proceed to Phase 2 transition (ask_user with next step guidance)\n\n**NEVER do this:**\n- ❌ Not generating the diagram and asking \"The architecture is confirmed. Shall we proceed to the next step?\"\n- ❌ Deferring diagram generation to Phase 2 or later\n- ❌ Saying \"I'll create the diagram later\"\n- ❌ Declaring \"architecture confirmed\" based solely on spec collection completion\n- ❌ Generating the diagram but NOT showing the configuration table\n- ❌ Skipping the \"anything to change?\" question and jumping straight to Phase 2\n\n**Validation condition**: Phase 2 entry is NOT allowed if the `01_arch_diagram_draft.html` file has not been generated.\n\n**Report format after diagram completion (ALL sections are MANDATORY):**\n```\n## Architecture Diagram\n\n[Interactive diagram link — auto-opened in browser]\n\n### Confirmed Configuration\n\n| Service | Type | SKU/Tier | Details |\n|---------|------|----------|---------|\n| [Service name] | [Azure resource type] | [SKU] | [Key config: model, capacity, etc.] |\n| ... | ... | ... | ... |\n\n**Networking**: [VNet + Private Endpoint / Public / etc.]\n**Location**: [confirmed region]\n```\n\n**After showing the report, immediately use `ask_user` with choices:**\n```\nask_user({\n  question: \"The architecture diagram and configuration are ready. What would you like to do?\",\n  choices: [\n    \"Looks good — proceed to Bicep code generation (Recommended)\",\n    \"I want to modify the architecture\",\n    \"Add more services\"\n  ]\n})\n```\n\n- If \"proceed\" → move to Phase 2 transition (collect subscription/RG info)\n- If \"modify\" or \"add\" → apply changes, regenerate diagram, show report again\n\n**🚨 The configuration table is NOT optional.** The user needs to visually verify what was confirmed before proceeding. Without the table, the user cannot validate the architecture.\n\n### 1-2. Interactive HTML Diagram Generation\n\nUse the built-in **diagram engine** (Python scripts included in the skill) to create an interactive HTML diagram.\nNo `pip install` is needed as the scripts are directly available in the `scripts/` folder, requiring no network connection or package installation.\n605+ official Azure icons are built in.\n\n**Diagram file naming convention:**\n\nAll diagrams are generated inside the Bicep project folder (`\u003cproject-name\u003e/`).\nThey are systematically managed with numbered prefixes per stage, and previous stage files are never overwritten.\n\n| Stage | File Name | When Generated |\n|-------|-----------|----------------|\n| Phase 1 design draft | `01_arch_diagram_draft.html` | When architecture design is confirmed |\n| Phase 4 What-if preview | `02_arch_diagram_preview.html` | After What-if validation |\n| Phase 4 deployment result | `03_arch_diagram_result.html` | After actual deployment completes |\n\n**Built-in module path discovery + Python path discovery:**\n\n**🚨 The Python path + built-in module path are verified once during Phase 1 preload, and reused for all subsequent diagram generations. Do NOT re-discover every time.**\n\n```powershell\n# ─── Step 1: Python Path Discovery ───\n# ⚠️ Get-Command python may pick up the Windows Store alias, so filesystem discovery is done first\n$PythonCmd = $null\n\n# Priority 1: Direct discovery of actual installation path (most reliable)\n$PythonExe = Get-ChildItem -Path \"$env:LOCALAPPDATA\\Programs\\Python\" -Filter \"python.exe\" -Recurse -ErrorAction SilentlyContinue |\n  Where-Object { $_.FullName -notlike '*WindowsApps*' } |\n  Select-Object -First 1 -ExpandProperty FullName\nif ($PythonExe) { $PythonCmd = $PythonExe }\n\n# Priority 2: Program Files discovery\nif (-not $PythonCmd) {\n  $PythonExe = Get-ChildItem -Path \"$env:ProgramFiles\\Python*\", \"$env:ProgramFiles(x86)\\Python*\" -Filter \"python.exe\" -Recurse -ErrorAction SilentlyContinue |\n    Select-Object -First 1 -ExpandProperty FullName\n  if ($PythonExe) { $PythonCmd = $PythonExe }\n}\n\n# Priority 3: Find in PATH (only if not a Windows Store alias)\nif (-not $PythonCmd) {\n  foreach ($cmd in @('python3', 'py')) {\n    $found = Get-Command $cmd -ErrorAction SilentlyContinue\n    if ($found -and $found.Source -notlike '*WindowsApps*') { $PythonCmd = $cmd; break }\n  }\n}\n\nif (-not $PythonCmd) {\n  Write-Host \"\"\n  Write-Host \"Python is not installed or not found in PATH.\" -ForegroundColor Red\n  Write-Host \"\"\n  Write-Host \"Please install using one of the following methods:\" -ForegroundColor Yellow\n  Write-Host \"  1. winget install Python.Python.3.12\"\n  Write-Host \"  2. Download from https://www.python.org/downloads/\"\n  Write-Host \"  3. Search for 'Python 3.12' in the Microsoft Store and install\"\n  Write-Host \"\"\n  Write-Host \"After installation, restart your terminal and try again.\"\n  return\n}\n\n# ─── Step 2: Built-in Script Path Discovery (no pip install needed) ───\n# Priority 1: Project local skill folder\n$ScriptsDir = Get-ChildItem -Path \".github\\skills\\azure-architecture-autopilot\" -Filter \"cli.py\" -Recurse -ErrorAction SilentlyContinue |\n  Where-Object { $_.Directory.Name -eq 'scripts' } |\n  Select-Object -First 1 -ExpandProperty DirectoryName\n# Priority 2: Global skill folder\nif (-not $ScriptsDir) {\n  $ScriptsDir = Get-ChildItem -Path \"$env:USERPROFILE\\.copilot\\skills\\azure-architecture-autopilot\" -Filter \"cli.py\" -Recurse -ErrorAction SilentlyContinue |\n    Where-Object { $_.Directory.Name -eq 'scripts' } |\n    Select-Object -First 1 -ExpandProperty DirectoryName\n}\n\n# ─── Step 3: Diagram Generation (CLI method — direct script execution) ───\n$OutputFile = \"\u003cproject-name\u003e\\01_arch_diagram_draft.html\"\n\n\u0026 $PythonCmd \"$ScriptsDir\\cli.py\" `\n  --services '\u003cservices_JSON\u003e' `\n  --connections '\u003cconnections_JSON\u003e' `\n  --title \"Architecture Title\" `\n  --vnet-info \"10.0.0.0/16 | pe-subnet: 10.0.1.0/24\" `\n  --output $OutputFile\n\n# Automatically open in browser after generation\nStart-Process $OutputFile\n```\n\n**Python API method is also available (alternative):**\n\nWhen JSON is very large, you can directly call the Python API to avoid CLI argument length limitations.\nAdd the scripts folder to `sys.path` to import the built-in module:\n\n```python\nimport sys, os\n# Add scripts folder to Python path (use built-in module without pip install)\nscripts_dir = r\"\u003cabsolute path to scripts folder\u003e\"  # $ScriptsDir value found in Step 2\nsys.path.insert(0, scripts_dir)\n\nfrom generator import generate_diagram\n\nservices = [...]   # services JSON\nconnections = [...] # connections JSON\n\nhtml = generate_diagram(\n    services=services,\n    connections=connections,\n    title=\"Architecture Title\",\n    vnet_info=\"10.0.0.0/16 | pe-subnet: 10.0.1.0/24\",\n    hierarchy=None  # Only used for multiple subscriptions/RGs\n)\n\nwith open(\"\u003cproject-name\u003e/01_arch_diagram_draft.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html)\n```\n\n**🔹 CLI vs Python API Selection Criteria:**\n\n| Scenario | Method | Reason |\n|----------|--------|--------|\n| 10 or fewer services | CLI (`python scripts/cli.py`) | Simple and fast |\n| More than 10 services or using hierarchy | Python API (sys.path addition) | Avoids CLI argument length limits |\n| Multi-subscription/RG diagrams | Python API + `hierarchy` parameter | Hierarchical structure representation |\n\n**Full list of supported service types:**\n\nAvailable in the skill's built-in reference files under `references/`.\nSupported service type values are listed below in the services JSON format section.\n\n\u003e **Diagram generation order**: (1) Verify Python path → (2) Verify built-in module path → (3) Compose services/connections JSON → (4) Execute. If Python is not installed, guide the user to install it before composing JSON. This prevents the waste of building JSON only to fail because Python is missing.\n\n\u003e **🚨 Automatic Diagram Open (No Exceptions)**: When an HTML file is generated with the built-in diagram engine, it **MUST always** be opened in the browser regardless of the situation. Without exception, whenever a diagram is (re)generated, execute the `Start-Process` command. Diagram generation and browser opening are always executed together in a single PowerShell command block.\n\u003e\n\u003e **When this applies (not just these, but ALL times an HTML diagram is generated):**\n\u003e - Phase 1 design draft (`01_arch_diagram_draft.html`)\n\u003e - Diagram regeneration after Delta Confirmation\n\u003e - Phase 4 What-if preview (`02_arch_diagram_preview.html`)\n\u003e - Phase 4 deployment result (`03_arch_diagram_result.html`)\n\u003e - Architecture changes after deployment (`04_arch_diagram_update_draft.html`)\n\u003e - Any other case where a diagram is regenerated for any reason\n\n**services JSON format:**\n\nDynamically composed based on the user's confirmed service list. Below is the JSON structure description.\n\n```json\n[\n  {\"id\": \"uniqueID\", \"name\": \"Service Display Name\", \"type\": \"iconType\", \"sku\": \"SKU\", \"private\": true/false,\n   \"details\": [\"Detail line 1\", \"Detail line 2\"]}\n]\n```\n\n| Field | Required | Type | Description |\n|-------|----------|------|-------------|\n| `id` | Yes | string | Unique identifier (kebab-case) |\n| `name` | Yes | string | Display name shown on diagram |\n| `type` | Yes | string | Service type (select from list below) |\n| `sku` | | string | SKU/tier information |\n| `private` | | boolean | Private Endpoint connected (default: false) |\n| `details` | | string[] | Additional info shown in sidebar |\n| `subscription` | | string | Subscription name (required when using hierarchy) |\n| `resourceGroup` | | string | Resource group name (required when using hierarchy) |\n\n**Service Type — Canonical Reference:**\n\n\u003e ⚠️ **CRITICAL**: Always use the **canonical type** from the table below. Do NOT use Azure ARM resource names (e.g., `private_endpoints`, `storage_accounts`, `data_factories`). The generator normalizes common variants, but using canonical types ensures correct icon rendering, PE detection, and color coding.\n\n| Category | Canonical Type | Azure Resource | Icon |\n|----------|---------------|----------------|------|\n| **AI** | `ai_foundry` | Microsoft.CognitiveServices/accounts (kind: AIServices) | AI Foundry |\n| | `openai` | Microsoft.CognitiveServices/accounts (kind: OpenAI) | Azure OpenAI |\n| | `ai_hub` | Foundry Project | AI Studio |\n| | `search` | Microsoft.Search/searchServices | Cognitive Search |\n| | `document_intelligence` | Microsoft.CognitiveServices/accounts (kind: FormRecognizer) | Form Recognizer |\n| | `aml` | Microsoft.MachineLearningServices/workspaces | Machine Learning |\n| **Data** | `fabric` | Microsoft.Fabric/capacities | Microsoft Fabric |\n| | `adf` | Microsoft.DataFactory/factories | Data Factory |\n| | `storage` | Microsoft.Storage/storageAccounts | Storage Account |\n| | `adls` | ADLS Gen2 (Storage with HNS) | Data Lake |\n| | `cosmos_db` | Microsoft.DocumentDB/databaseAccounts | Cosmos DB |\n| | `sql_database` | Microsoft.Sql/servers/databases | SQL Database |\n| | `sql_server` | Microsoft.Sql/servers | SQL Server |\n| | `databricks` | Microsoft.Databricks/workspaces | Databricks |\n| | `synapse` | Microsoft.Synapse/workspaces | Synapse Analytics |\n| | `redis` | Microsoft.Cache/redis | Redis Cache |\n| | `stream_analytics` | Microsoft.StreamAnalytics/streamingjobs | Stream Analytics |\n| | `postgresql` | Microsoft.DBforPostgreSQL/flexibleServers | PostgreSQL |\n| | `mysql` | Microsoft.DBforMySQL/flexibleServers | MySQL |\n| **Security** | `keyvault` | Microsoft.KeyVault/vaults | Key Vault |\n| | `sentinel` | Microsoft.SecurityInsights | Sentinel |\n| **Compute** | `appservice` | Microsoft.Web/sites | App Service |\n| | `function_app` | Microsoft.Web/sites (kind: functionapp) | Function App |\n| | `vm` | Microsoft.Compute/virtualMachines | Virtual Machine |\n| | `aks` | Microsoft.ContainerService/managedClusters | AKS |\n| | `acr` | Microsoft.ContainerRegistry/registries | Container Registry |\n| | `container_apps` | Microsoft.App/containerApps | Container Apps |\n| | `static_web_app` | Microsoft.Web/staticSites | Static Web App |\n| | `spring_apps` | Microsoft.AppPlatform/Spring | Spring Apps |\n| **Network** | `pe` | Microsoft.Network/privateEndpoints | Private Endpoint |\n| | `vnet` | Microsoft.Network/virtualNetworks | VNet |\n| | `nsg` | Microsoft.Network/networkSecurityGroups | NSG |\n| | `firewall` | Microsoft.Network/azureFirewalls | Firewall |\n| | `bastion` | Microsoft.Network/bastionHosts | Bastion |\n| | `app_gateway` | Microsoft.Network/applicationGateways | App Gateway |\n| | `front_door` | Microsoft.Cdn/profiles (Front Door) | Front Door |\n| | `vpn` | Microsoft.Network/virtualNetworkGateways | VPN Gateway |\n| | `load_balancer` | Microsoft.Network/loadBalancers | Load Balancer |\n| | `nat_gateway` | Microsoft.Network/natGateways | NAT Gateway |\n| | `cdn` | Microsoft.Cdn/profiles | CDN |\n| **IoT** | `iot_hub` | Microsoft.Devices/IotHubs | IoT Hub |\n| | `digital_twins` | Microsoft.DigitalTwins/digitalTwinsInstances | Digital Twins |\n| **Integration** | `event_hub` | Microsoft.EventHub/namespaces | Event Hub |\n| | `event_grid` | Microsoft.EventGrid/topics | Event Grid |\n| | `apim` | Microsoft.ApiManagement/service | API Management |\n| | `service_bus` | Microsoft.ServiceBus/namespaces | Service Bus |\n| | `logic_apps` | Microsoft.Logic/workflows | Logic Apps |\n| **Monitoring** | `log_analytics` | Microsoft.OperationalInsights/workspaces | Log Analytics |\n| | `appinsights` | Microsoft.Insights/components | App Insights |\n| | `monitor` | Azure Monitor | Monitor |\n| **Other** | `jumpbox`, `user`, `devops` | — | Special |\n\n**When Using Private Endpoints — PE Node Addition Required:**\n\nIf Private Endpoints are included in the architecture, a PE node MUST be added to the services JSON for each service, and connections must also include the PE links for them to appear in the diagram.\n\n```json\n// Add PE node corresponding to each service\n{\"id\": \"pe_serviceID\", \"name\": \"PE: ServiceName\", \"type\": \"pe\", \"details\": [\"groupId: correspondingGroupID\"]}\n\n// Add service → PE connection in connections\n{\"from\": \"serviceID\", \"to\": \"pe_serviceID\", \"label\": \"\", \"type\": \"private\"}\n```\n\n**🚨🚨🚨 PE Connections and Business Logic Connections Are Separate — BOTH MUST Be Included 🚨🚨🚨**\n\nPE connections (`\"type\": \"private\"`) represent network isolation. But this alone does NOT show the actual **data flow/API calls** between services in the diagram.\n\n**MUST include both types of connections:**\n\n1. **Business logic connections** — Actual data flow between services (api, data, security types)\n2. **PE connections** — Network isolation between service ↔ PE (private type)\n\n```json\n// ✅ Correct example — Function App → Foundry\n// 1) Business logic: Function App calls Foundry for chat/embedding\n{\"from\": \"func_app\", \"to\": \"foundry\", \"label\": \"RAG Chat + Embedding\", \"type\": \"api\"}\n// 2) PE connection: Foundry's Private Endpoint\n{\"from\": \"foundry\", \"to\": \"pe_foundry\", \"label\": \"\", \"type\": \"private\"}\n\n// ❌ Wrong example — Only PE connection, no business logic connection\n{\"from\": \"foundry\", \"to\": \"pe_foundry\", \"label\": \"\", \"type\": \"private\"}\n// → No connection line between Function App and Foundry in the diagram, so the architecture flow is not visible\n```\n\n**NEVER do this:**\n- Create only PE connections and omit business logic connections\n- Connect `from`/`to` of business logic connections to PE nodes (use the **actual service ID**, not the PE)\n- Assume \"the PE is there so the connection line will show up\"\n\nThe PE groupId differs by service. Refer to the PE groupId \u0026 DNS Zone mapping table in `references/service-gotchas.md`.\n\n\u003e **Service naming convention**: MUST use the latest official Azure names. If uncertain about the name, verify with MS Docs.\n\u003e For resource types and key properties per service, refer to `references/ai-data.md`.\n\n**connections JSON format:**\n```json\n[\n  {\"from\": \"serviceA_ID\", \"to\": \"serviceB_ID\", \"label\": \"Connection description\", \"type\": \"api|data|security|private\"}\n]\n```\n\n**Connection Types:**\n\n| type | Color | Style | Use For |\n|------|-------|-------|---------|\n| `api` | Blue | Solid | API calls, queries |\n| `data` | Green | Solid | Data flow, indexing |\n| `security` | Orange | Dashed | Secrets, auth |\n| `private` | Purple | Dashed | Private Endpoint connections |\n| `network` | Gray | Solid | Network routing |\n| `default` | Gray | Solid | Other |\n\n**🔹 Diagram Multilingual Principle:**\n- The `name`, `details` in services and `label` in connections are written in **the user's language**\n- Example: `\"label\": \"RAG Search\"`, `\"label\": \"Data Ingestion\"`\n- Official Azure service names (Microsoft Foundry, AI Search, etc.) are always in English regardless of language\n\n**🔹 VNet Node — Do NOT add to services JSON:**\n- VNet is automatically displayed as a **purple dashed boundary** in the diagram (when PEs are present)\n- Adding a separate VNet node to services JSON causes confusion by duplicating with the boundary line\n- VNet information (CIDR, subnets) is sufficiently conveyed through the sidebar VNet boundary label\n\nProvide the full path of the generated HTML file to the user.\n\n### 1-3. Finalizing Architecture Through Conversation\n\nThe architecture is finalized incrementally through conversation with the user. When the user requests changes, do NOT ask everything from scratch; instead, **reflect only the requested changes based on the current confirmed state** and regenerate the diagram.\n\n**⚠️ Delta Confirmation Rule — Required Verification on Service Addition/Change:**\n\nService addition/change is not a \"simple update\" — it is an **event that reopens undecided required fields for that service**.\n\n**Process:**\n1. Diff the current confirmed state + new request\n2. Identify the required fields for newly added services (refer to `domain-packs` or MS Docs)\n3. Fetch the region availability/options for the service from MS Docs\n4. If any required fields are undecided, **ask the user via ask_user first**\n5. **Regenerate the diagram only after confirmation is complete**\n\n**NEVER do this:**\n- Finalize diagram update while required fields remain undecided\n- Arbitrarily add sub-components/workloads the user did not mention (e.g., automatically adding OneLake and data pipeline to a Fabric request)\n- Vaguely assume SKU/model like \"F SKU\" without confirmation\n\n**Do not re-ask settings for already confirmed services.** Only confirm undecided items for newly added/changed services.\n\n---\n\n**🚨🚨🚨 [Top Priority Principle] Immediate Fact Check During Design Phase 🚨🚨🚨**\n\n**The purpose of Phase 1 is to confirm a \"feasible architecture\".**\n**No matter what the user requests, before reflecting it in the diagram, you MUST fact-check whether it is actually possible by directly querying MS Docs via web_fetch.**\n\n**Design Direction vs Deployment Specs — Separate Information Paths:**\n\n| Decision Type | Reference Path | Examples |\n|--------------|----------------|----------|\n| **Design direction** (architecture patterns, best practices, service combinations) | `references/architecture-guidance-sources.md` → targeted fetch | \"What's the recommended RAG structure?\", \"Enterprise baseline?\" |\n| **Deployment specs** (API version, SKU, region, model, PE mapping) | `references/azure-dynamic-sources.md` → MS Docs fetch | \"What's the API version?\", \"Is this model available in Korea Central?\" |\n\n- **Design direction comes from architecture guidance, actual deployment values from dynamic sources.** Do not mix these two paths.\n- Do NOT use Architecture guidance document content to determine SKU/API version/region.\n- **Do NOT crawl through all Architecture Center sub-documents for every request.** Perform trigger-based targeted fetch of at most 2 relevant documents.\n- For trigger/fetch budget/decision rules by question type, refer to `architecture-guidance-sources.md`.\n\n**This principle applies to ALL requests without exception:**\n- Model addition/change → Verify in MS Docs whether the model exists and can be deployed in the target region\n- Service addition/change → Verify in MS Docs whether the service is available in the target region\n- SKU change → Verify in MS Docs whether the SKU is valid and supports the desired features\n- Feature request → Verify in MS Docs whether the feature is actually supported\n- Service combination → Verify in MS Docs whether inter-service integration is possible\n- **Any other request** → Fact-check with MS Docs\n\n**MS Docs verification results:**\n- **Possible** → Reflect in diagram\n- **Not possible** → Immediately explain the reason to the user and suggest available alternatives\n\n**Fact Check Process — Cross-Verification Required:**\n\nDo not simply query once and move on for user requests.\n**Cross-verification using other MS Docs pages/sources MUST always be performed.**\n\n\u003e **GHCP Environment Constraint**: Sub-agents (explore/task/general-purpose) do NOT have `web_fetch`/`web_search` tools.\n\u003e Therefore, verification requiring MS Docs queries MUST be performed **directly by the main agent**.\n\n```\n[1st Verification] Main agent directly queries MS Docs via web_fetch (primary page)\n    ↓\n[2nd Verification] Main agent additionally fetches other/related MS Docs pages via web_fetch for cross-checking\n    - e.g., Model availability → 1st: models page / 2nd: regional availability or pricing page\n    - e.g., API version → 1st: Bicep reference page / 2nd: REST API reference page\n    - Compare 1st and 2nd results and flag any discrepancies\n    ↓\n[Consolidate Results] If both verifications match, respond to the user\n    - On discrepancy: Resolve with additional queries, or honestly inform the user about the uncertainty\n```\n\n**Fact Check Quality Standards — Be Thorough, Not Cursory:**\n- When a MS Docs page is fetched, **check ALL relevant sections, tabs, and conditions without omission**\n- When checking model availability: Check **ALL deployment types** including Global Standard, Standard, Provisioned, Data Zone, etc. Do NOT conclude \"not supported\" based on only one deployment type\n- When checking SKUs: **Fully** verify the feature list supported by that SKU\n- If the page is large, fetch relevant sections **multiple times** to ensure accuracy\n- If uncertain, query additional pages. **NEVER answer based on guesswork**\n\n**NEVER do this:**\n- Add to the diagram without verification\n- Defer verification with \"I'll check during Bicep generation\" or \"It will be validated during deployment\"\n- Rely only on your memory and answer \"it should work\" — **MUST directly query MS Docs**\n- Fetch MS Docs but rush to conclusions after only partially reading\n- Finalize based on a single query — **MUST cross-verify with another source**\n\n**🚫 Sub-Agent Usage Rules:**\n\n**Sub-agents in GHCP = `task` tool:**\n- `agent_type: \"explore\"` — Read-only tasks like codebase exploration, file search (**web_fetch/web_search NOT available**)\n- `agent_type: \"task\"` — Command execution like az cli, bicep build\n- `agent_type: \"general-purpose\"` — High-level tasks like complex Bicep generation\n\n\u003e **⚠️ Sub-agent tool constraint**: ALL sub-agents (explore/task/general-purpose) CANNOT use `web_fetch` or `web_search`.\n\u003e Fact checks requiring MS Docs queries, API version verification, model availability checks, etc. MUST be performed **directly by the main agent**.\n\n**Foreground vs Background Decision Criteria:**\n- **If results are needed before proceeding to the next step → `mode: \"sync\"` (default)**\n  - e.g., Query SKU list then provide choices to user, verify model availability then reflect in diagram\n  - Running in background here would leave the user idle waiting for results\n- **If there is other independent work that can be done while waiting for results → `mode: \"background\"`**\n  - e.g., Simultaneously web_fetch multiple MS Docs pages for cross-verification\n\n**Most fact checks should be run in foreground (`mode: \"sync\"`)** because the next question cannot be asked without the results.\n\n**How to run cross-verification in parallel:**\n```\n// Execute 1st and 2nd verification simultaneously (main agent performs directly)\n[Simultaneously] Directly query primary MS Docs page via web_fetch (1st)\n[Simultaneously] Additionally query related MS Docs page via web_fetch (2nd)\n// Compare both results to check for discrepancies\n// e.g., Model availability → parallel fetch of models page + regional availability page\n```\n\n**NEVER do this:**\n- Run in background when results are needed, then sit idle doing nothing while waiting\n- Delegate tasks requiring web_fetch/web_search to sub-agents (main agent MUST perform directly)\n- Attempt to directly read files internal to sub-agents\n\n---\n\n**⚠️ Important: Do NOT execute any shell commands until the user explicitly approves proceeding to the next step.**\nHowever, MS Docs web_fetch for the above fact checks is exceptionally allowed.\n\nOnce the architecture is confirmed (user said no changes to the diagram), ask the user whether to proceed to the next step.\n\n**🚨 Phase 2 Transition Prerequisites — ALL of the following must be met before asking this question:**\n\n1. `01_arch_diagram_draft.html` has been **generated** using the built-in diagram engine\n2. The diagram has been **opened in the browser** and **displayed to the user** in the report format with the **configuration table**\n3. The user was asked **\"Would you like to change or add anything?\"** and responded with **no changes**, or modifications have been reflected and **final confirmation** is given\n\n**If ANY of the above conditions are not met, do NOT proceed to Phase 2.**\nIf the diagram does not exist yet, **generate it right now** — follow the procedure in section 1-2.\nIf the configuration table was not shown, **show it right now** before asking about changes.\n\n**Following the parallel preload principle, execute `az account list` and `az group list` simultaneously with ask_user to prepare subscription/RG choices in advance.**\n\n```\n// Call simultaneously in the same response:\n[1] ask_user — \"The architecture is confirmed! Shall we proceed to the next step?\"\n[2] powershell — az account show 2\u003e\u00261              (pre-check login status)\n[3] powershell — az account list --output json      (pre-prepare subscription choices)\n[4] powershell — az group list --output json        (pre-prepare resource group choices)\n```\n\nask_user display format:\n```\nThe architecture is confirmed! Shall we proceed to the next step?\n\n✅ Confirmed architecture: [summary]\n\nThe following steps will proceed:\n1. [Bicep Code Generation] — AI automatically writes IaC code\n2. [Code Review] — Automated security/best practice review\n3. [Azure Deployment] — Actual resource creation (optional)\n\nShall we proceed? (If you'd like just the code without deployment, let me know)\n```\n\nOnce the user approves, collect information in the following order.\n**Since `az account show` + `az account list` + `az group list` were already completed during preload, subscription/RG choices can be presented immediately.**\n\n**Step 1: Azure Login Verification**\n\nThe `az account show` result is already available from preload. No additional call needed.\n\n- If logged in → Move to Step 2\n- If not logged in → Guide the user:\n  ```\n  Azure CLI login is required. Please run the following command in your terminal:\n  az login\n  Please let me know once completed.\n  ```\n\n**Step 2: Subscription Selection**\n\nThe `az account list` result is already available from preload. No additional call needed.\n\nProvide up to 4 subscriptions from the query results as `ask_user` choices.\nIf there are 5 or more, include the 3-4 most frequently used subscriptions as choices (users can also type a custom input).\nOnce the user selects, execute `az account set --subscription \"\u003cID\u003e\"`.\n\n**Step 3: Resource Group Confirmation**\n\nThe `az group list` result is already available from preload. No additional call needed.\n\nProvide up to 4 existing resource groups from the list as `ask_user` choices.\nIf the user selects an existing group, use it as-is; if they type a new name as custom input, create it during Phase 4 deployment.\n\n**Required confirmed items:**\n- [ ] Service list and SKUs\n- [ ] Networking method (Private Endpoint usage)\n- [ ] Subscription ID (confirmed in Step 2)\n- [ ] Resource group name (confirmed in Step 3)\n- [ ] Location (confirmed with user — regional availability per service verified via MS Docs)\n\n---\n\n## 🚨 Phase 1 Completion Checklist — Required Verification Before Phase 2 Entry\n\nBefore leaving Phase 1, verify **ALL** items below. If any are incomplete, do NOT proceed to Phase 2.\n\n| # | Item | Verification Method |\n|---|------|---------------------|\n| 1 | All required specs confirmed | Project name, services, SKUs, region, and networking method are all confirmed |\n| 2 | Fact check completed | MS Docs cross-verification has been performed |\n| 3 | **Diagram generated** | `01_arch_diagram_draft.html` file has been generated using the built-in diagram engine |\n| 4 | **Configuration table shown** | Detailed table with Service/Type/SKU/Details displayed to user in report format |\n| 5 | **User reviewed diagram** | Browser auto-open + report format + \"anything to change?\" question asked |\n| 6 | User final approval | User confirmed no changes, then selected \"proceed to next step\" |\n\n**⚠️ Do NOT ask item 6 while items 3-5 are incomplete.** The flow must be: diagram → table → ask changes → confirm → next step.\n\n---\n\n## Phase 2 Handoff: Bicep Generation Agent\n\nOnce the user agrees to proceed, read the `references/bicep-generator.md` instructions and generate the Bicep template.\nAlternatively, this can be delegated to a separate sub-agent.\n\n**Sensitive Information Handling Principle (NEVER violate):**\n- NEVER ask for VM passwords, API keys, or other sensitive values in chat, and NEVER store them in parameter files\n- During code review, if sensitive values are found in plaintext in `main.bicepparam`, remove them immediately\n\n**🔹 User-Input Sensitive Values Like VM Passwords — Complexity Validation Required:**\n\nWhen the user inputs a VM admin password or similar, validate complexity requirements **before** sending to Azure.\nAzure VMs must satisfy ALL of the following conditions:\n- 12 characters or more\n- Contains at least 3 of: uppercase letters, lowercase letters, numbers, special characters\n\n**On validation failure:** Do NOT attempt deployment; immediately ask the user to re-enter:\n\u003e **⚠️ The password does not meet Azure complexity requirements.** It must be 12 characters or more and contain at least 3 of: uppercase + lowercase + numbers + special characters.\n\n**NEVER do this:**\n- Warn \"it may not meet requirements\" but attempt deployment anyway — **MUST block**\n- Send to Azure without complexity validation, causing deployment failure\n\n**🚨 `@secure()` Parameter and `.bicepparam` Compatibility Principle:**\n\nWhen a `.bicepparam` file has a `using './main.bicep'` directive, additional `--parameters` flags CANNOT be used together with `az deployment group what-if/create`.\nTherefore, `@secure()` parameter handling follows these rules:\n\n1. **`@secure()` parameters MUST have default values** — Use Bicep functions like `newGuid()`, `uniqueString()`\n   ```bicep\n   @secure()\n   param sqlAdminPassword string = newGuid()  // Auto-generated at deployment, store in Key Vault if needed\n   ```\n2. **If there are `@secure()` parameters that require user-specified values:**\n   - Do NOT use `.bicepparam` file; instead use `--template-file` + `--parameters` combination\n   - Or generate a separate JSON parameter file (`main.parameters.json`)\n   ```powershell\n   # When .bicepparam cannot be used — substitute with JSON parameter file\n   az deployment group what-if `\n     --template-file main.bicep `\n     --parameters main.parameters.json `\n     --parameters sqlAdminPassword='user-input-value'\n   ```\n3. **Do NOT use `.bicepparam` and `--parameters` simultaneously in a deployment command**\n   ```\n   ❌ az deployment group create --parameters main.bicepparam --parameters key=value\n   ✅ az deployment group create --parameters main.bicepparam\n   ✅ az deployment group create --template-file main.bicep --parameters main.parameters.json --parameters key=value\n   ```\n\n**Decision criteria:**\n- All `@secure()` parameters have default values (newGuid, etc.) → `.bicepparam` can be used\n- Any `@secure()` parameter requires user input → Use JSON parameter file instead of `.bicepparam`\n\n**When MS Docs fetch fails:**\n- If web_fetch fails due to rate limiting, etc., MUST notify the user:\n  ```\n  ⚠️ MS Docs API version lookup failed. Generating with the last known stable version.\n  Verifying the actual latest version before deployment is recommended.\n  Shall we continue?\n  ```\n- Do NOT silently proceed with a hardcoded version without user approval\n\n**Pre-Bicep generation reference files:**\n- `references/service-gotchas.md` — Required properties, common mistakes, PE groupId/DNS Zone mapping\n- `references/ai-data.md` — AI/Data service configuration guide (v1 domain)\n- `references/azure-common-patterns.md` — PE/security/naming common patterns\n- `references/azure-dynamic-sources.md` — MS Docs URL registry (for API version fetch)\n- For services not covered in the above files, directly fetch MS Docs to verify resource types, properties, and PE mappings\n\n**Output structure:**\n```\n\u003cproject-name\u003e/\n├── main.bicep              # Main orchestration\n├── main.bicepparam         # Parameters (environment-specific values)\n└── modules/\n    ├── network.bicep       # VNet, Subnet (including private endpoint subnet)\n    ├── ai.bicep            # AI services (configured per user requirements)\n    ├── storage.bicep       # ADLS Gen2 (isHnsEnabled: true)\n    ├── fabric.bicep        # Microsoft Fabric (if needed)\n    ├── keyvault.bicep      # Key Vault\n    └── private-endpoints.bicep  # All PEs + DNS Zones\n```\n\n**Bicep mandatory principles:**\n- Parameterize all resource names — `param openAiName string = 'oai-${uniqueString(resourceGroup().id)}'`\n- Private services MUST have `publicNetworkAccess: 'Disabled'`\n- Set `privateEndpointNetworkPolicies: 'Disabled'` on pe-subnet\n- Private DNS Zone + VNet Link + DNS Zone Group — all 3 required\n- When using Microsoft Foundry, **Foundry Project (`accounts/projects`) MUST be created alongside** — without it, the portal is unusable\n- ADLS Gen2 MUST have `isHnsEnabled: true` (omitting this creates a regular Blob Storage)\n- Store secrets in Key Vault, reference via `@secure()` parameters\n- Add English comments explaining the purpose of each section\n\nImmediately transition to Phase 3 after generation is complete.\n\n---\n\n## Phase 3 Handoff: Bicep Review Agent\n\nReview according to `references/bicep-reviewer.md` instructions.\n\n**⚠️ Key Point: Do NOT just visually inspect and say \"pass\". You MUST run `az bicep build` to verify actual compilation results.**\n\n```powershell\naz bicep build --file main.bicep 2\u003e\u00261\n```\n\n1. Compilation errors/warnings → Fix\n2. Checklist review → Fix\n3. Re-compile to confirm\n4. Report results (including compilation results)\n\nFor detailed checklists and fix procedures, see `references/bicep-reviewer.md`.\n\nAfter review is complete, show the user the results before transitioning to Phase 4, and **MUST guide the user on the next steps.**\n\n**🚨 Required Report Format When Phase 3 Is Complete:**\n\n```\n## Bicep Code Review Complete\n\n[Review result summary — bicep-reviewer.md Step 6 format]\n\n---\n\n**Next Step: Phase 4 (Azure Deployment)**\n\nThe review is complete. The following steps will proceed:\n1. **What-if Validation** — Preview planned resources without making actual changes\n2. **Preview Diagram** — Architecture visualization based on What-if results (02_arch_diagram_preview.html)\n3. **Actual Deployment** — Create resources in Azure after user confirmation\n\nShall we proceed with deployment? (If you'd like just the code without deployment, let me know)\n```\n\n**NEVER do this:**\n- Completing Phase 3 and just providing the `az deployment group create` command without further guidance\n- Deploying directly without What-if validation, or telling the user to run commands themselves\n- Skipping the Phase 4 steps (What-if → Preview Diagram → Deployment)\n","references/phase4-deployer.md":"# Phase 4: Deployment Agent\n\nThis file contains detailed instructions for Phase 4. Read and follow this file when the user approves deployment after Phase 3 (code review) is complete.\n\n---\n\n**🚨🚨🚨 Phase 4 Mandatory Execution Order — Never Skip Any Step 🚨🚨🚨**\n\nThe following 5 steps must be executed **strictly in order**. No step may be omitted or skipped.\nEven if the user requests deployment with \"deploy it\", \"go ahead\", \"do it\", etc., always proceed from Step 1 in order.\n\n```\nStep 1: Verify prerequisites (az login, subscription, resource group)\n    ↓\nStep 2: What-if validation (az deployment group what-if) ← Must execute\n    ↓\nStep 3: Generate preview diagram (02_arch_diagram_preview.html) ← Must generate\n    ↓\nStep 4: Actual deployment after user final confirmation (az deployment group create)\n    ↓\nStep 5: Generate deployment result diagram (03_arch_diagram_result.html)\n```\n\n**Never do the following:**\n- Execute `az deployment group create` directly without What-if\n- Skip generating the preview diagram (`02_arch_diagram_preview.html`)\n- Proceed with deployment without showing What-if results to the user\n- Only provide `az` commands for the user to run manually\n\n---\n\n### Step 1: Verify Prerequisites\n\n```powershell\n# Verify az CLI installation and login\naz account show 2\u003e\u00261\n```\n\nIf not logged in, ask the user to run `az login`.\nThe agent must never enter or store credentials directly.\n\nCreate resource group:\n```powershell\naz group create --name \"\u003cRG_NAME\u003e\" --location \"\u003cLOCATION\u003e\"  # Location confirmed in Phase 1\n```\n→ Proceed to next step after confirming success\n\n### Step 2: Validate → What-if Validation — 🚨 Mandatory\n\n**Do not skip this step. Always execute it no matter how urgently the user requests deployment.**\n\n**Step 2-A: Run Validate First (Quick Pre-validation)**\n\n`what-if` can **hang indefinitely without error messages** when there are Azure policy violations, resource reference errors, etc.\nTo prevent this, **always run `validate` first**. Validate returns errors quickly.\n\n```powershell\n# validate — Quickly catches policy violations, schema errors, parameter issues\naz deployment group validate `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --parameters main.bicepparam\n```\n\n- **Validate succeeds** → Proceed to Step 2-B (what-if)\n- **Validate fails** → Analyze error messages, fix Bicep, recompile, re-validate\n  - Azure Policy violation (`RequestDisallowedByPolicy`) → Reflect policy requirements in Bicep (e.g., `azureADOnlyAuthentication: true`)\n  - Schema error → Fix API version/properties\n  - Parameter error → Fix parameter file\n\n**Step 2-B: Run What-if**\n\nRun what-if after validate passes.\n\n**Choose parameter passing method:**\n- If all `@secure()` parameters have default values → Use `.bicepparam`\n- If `@secure()` parameters require user input → Use `--template-file` + JSON parameter file\n\n```powershell\n# Method 1: Use .bicepparam (when all @secure() parameters have defaults)\naz deployment group what-if `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --parameters main.bicepparam\n\n# Method 2: Use JSON parameter file (when @secure() parameters require user input)\naz deployment group what-if `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --template-file main.bicep `\n  --parameters main.parameters.json `\n  --parameters secureParam='value'\n```\n→ Summarize the What-if results and present them to the user.\n\n**⏱️ What-if Execution Method and Timeout Handling:**\n\nWhat-if performs resource validation on the Azure server side, so it may take time depending on the service/region.\n**Always execute with `initial_wait: 300` (5 minutes).** If not completed within 5 minutes, it automatically times out.\n\n```powershell\n# Always set initial_wait: 300 when calling the powershell tool\n# mode: \"sync\", initial_wait: 300\naz deployment group what-if `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --parameters main.bicepparam\n```\n\n**Completed within 5 minutes** → Proceed normally (summarize results → preview diagram → deployment confirmation)\n\n**Not completed within 5 minutes (timeout)** → Immediately stop with `stop_powershell` and offer choices to the user:\n\n```\nask_user({\n  question: \"What-if validation did not complete within 5 minutes. The Azure server response is delayed. How would you like to proceed?\",\n  choices: [\n    \"Retry (Recommended)\",\n    \"Skip What-if and deploy directly\"\n  ]\n})\n```\n\n**If \"Retry\" is selected:** Re-execute the same command with `initial_wait: 300`. Retry up to 2 times maximum.\n**If \"Skip What-if and deploy directly\" is selected:**\n- Generate the preview diagram based on the Phase 1 draft\n- Inform the user of the risks:\n  \u003e **⚠️ Deploying without What-if validation.** Unexpected resource changes may occur. Please verify in the Azure Portal after deployment.\n\n**Never do the following:**\n- Execute without setting `initial_wait`, causing indefinite waiting\n- Let the agent arbitrarily decide \"what-if is optional\" and skip it\n- Automatically switch to deployment without asking the user on timeout\n- Skip what-if for reasons like \"deployment is faster\"\n\n### Step 3: Preview Diagram Based on What-if Results — 🚨 Mandatory\n\n**Do not skip this step. Always generate the preview diagram when What-if succeeds.**\n\nRegenerate the diagram using the actual resources to be deployed (resource names, types, locations, counts) from the What-if results.\nKeep the draft from Phase 1 (`01_arch_diagram_draft.html`) as-is, and generate the preview as `02_arch_diagram_preview.html`.\nThe draft can be reopened at any time.\n\n```\n## Architecture to Be Deployed (Based on What-if)\n\n[Interactive diagram link — 02_arch_diagram_preview.html]\n(Design draft: 01_arch_diagram_draft.html)\n\nResources to be created (N items):\n[What-if results summary table]\n\nDeploy these resources? (Yes/No)\n```\n\nProceed to Step 4 when the user confirms. **Do not proceed to deployment without the preview diagram.**\n\n### Step 4: Actual Deployment\n\nExecute only when the user has reviewed the preview diagram and What-if results and approved the deployment.\n**Use the same parameter passing method used in What-if.**\n\n```powershell\n$deployName = \"deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')\"\n\n# Method 1: Use .bicepparam\naz deployment group create `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --parameters main.bicepparam `\n  --name $deployName `\n  2\u003e\u00261 | Tee-Object -FilePath deployment.log\n\n# Method 2: Use JSON parameter file\naz deployment group create `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --template-file main.bicep `\n  --parameters main.parameters.json `\n  --name $deployName `\n  2\u003e\u00261 | Tee-Object -FilePath deployment.log\n```\n\nPeriodically monitor progress during deployment:\n```powershell\naz deployment group show `\n  --resource-group \"\u003cRG_NAME\u003e\" `\n  --name \"\u003cDEPLOYMENT_NAME\u003e\" `\n  --query \"{status:properties.provisioningState, duration:properties.duration}\" `\n  -o table\n```\n\n### Handling Deployment Failures\n\nWhen deployment fails, some resources may remain in a 'Failed' state. Redeploying in this state causes errors like `AccountIsNotSucceeded`.\n\n**⚠️ Resource deletion is a destructive command. Always explain the situation to the user and obtain approval before executing.**\n\n```\n[Resource name] failed during deployment.\nTo redeploy, the failed resources must be deleted first.\n\nDelete and redeploy? (Yes/No)\n```\n\nDelete failed resources and redeploy once the user approves.\n\n**🔹 Handling Soft-deleted Resources (Prevent Redeployment Blocking):**\n\nWhen a resource group is deleted after a failed deployment, Cognitive Services (Foundry), Key Vault, etc. remain in a **soft-delete state**.\nRedeploying with the same name causes `FlagMustBeSetForRestore`, `Conflict` errors.\n\n**Always check before redeployment:**\n```powershell\n# Check soft-deleted Cognitive Services\naz cognitiveservices account list-deleted -o table\n\n# Check soft-deleted Key Vault\naz keyvault list-deleted -o table\n```\n\n**Resolution options (provide choices to the user):**\n```\nask_user({\n  question: \"Soft-deleted resources from a previous deployment were found. How would you like to handle this?\",\n  choices: [\n    \"Purge and redeploy (Recommended) - Clean delete then create new\",\n    \"Redeploy in restore mode - Recover existing resources\"\n  ]\n})\n```\n\n**Caution — Key Vault with `enablePurgeProtection: true`:**\n- Cannot be purged (must wait until retention period expires)\n- Cannot recreate with the same name\n- **Solution: Change the Key Vault name** and redeploy (e.g., add timestamp to `uniqueString()` seed)\n- Explain the situation to the user and guide them on the name change\n\n### Step 5: Deployment Complete — Generate Diagram from Actual Resources and Report\n\nOnce deployment is complete, query the actually deployed resources and generate the final architecture diagram.\n\n**Step 1: Query Deployed Resources**\n```powershell\naz resource list --resource-group \"\u003cRG_NAME\u003e\" --output json\n```\n\n**Step 2: Generate Diagram from Actual Resources**\n\nExtract resource names, types, SKUs, and endpoints from the query results and generate the final diagram using the built-in diagram engine.\nBe careful with file names to avoid overwriting previous diagrams:\n- `01_arch_diagram_draft.html` — Design draft (keep)\n- `02_arch_diagram_preview.html` — What-if preview (keep)\n- `03_arch_diagram_result.html` — Deployment result final version\n\nPopulate the diagram's services JSON with actual deployed resource information:\n- `name`: Actual resource name (e.g., `foundry-duru57kxgqzxs`)\n- `sku`: Actual SKU\n- `details`: Actual values such as endpoints, location, etc.\n\n**Step 3: Report**\n```\n## Deployment Complete!\n\n[Interactive architecture diagram — 03_arch_diagram_result.html]\n(Design draft: 01_arch_diagram_draft.html | What-if preview: 02_arch_diagram_preview.html)\n\nCreated resources (N items):\n[Dynamically extracted resource names, types, and endpoints from actual deployment results]\n\n## Next Steps\n1. Verify resources in Azure Portal\n2. Check Private Endpoint connection status\n3. Additional configuration guidance if needed\n\n## Cleanup Command (If Needed)\naz group delete --name \u003cRG_NAME\u003e --yes --no-wait\n```\n\n---\n\n### Handling Architecture Change Requests After Deployment\n\n**When the user requests resource additions/changes/deletions after deployment is complete, do NOT go directly to Bicep/deployment.**\nAlways return to Phase 1 and update the architecture first.\n\n**Process:**\n\n1. **Confirm user intent** — Ask first whether they want to add to the existing deployed architecture:\n   ```\n   Would you like to add a VM to the currently deployed architecture?\n   Current configuration: [Deployed services summary]\n   ```\n\n2. **Return to Phase 1 — Apply Delta Confirmation Rule**\n   - Use the existing deployment result (`03_arch_diagram_result.html`) as the current state baseline\n   - Verify required fields for new services (SKU, networking, region availability, etc.)\n   - Confirm undecided items via ask_user\n   - Fact-check (MS Docs fetch + cross-validation)\n\n3. **Generate Updated Architecture Diagram**\n   - Combine existing deployed resources + new resources into `04_arch_diagram_update_draft.html`\n   - Show to the user and get confirmation:\n   ```\n   ## Updated Architecture\n\n   [Interactive diagram — 04_arch_diagram_update_draft.html]\n   (Previous deployment result: 03_arch_diagram_result.html)\n\n   **Changes:**\n   - Added: [New services list]\n   - Removed: [Removed services list] (if any)\n\n   Proceed with this configuration?\n   ```\n\n4. **After confirmation, proceed through Phase 2 → 3 → 4 in order**\n   - Incrementally add new resource modules to existing Bicep\n   - Review → What-if → Deploy (incremental deployment)\n\n**Never do the following:**\n- Jump directly to Bicep generation without updating the architecture diagram when a change is requested after deployment\n- Ignore the existing deployment state and create new resources in isolation\n- Proceed without confirming with the user whether to add to the existing architecture\n","references/service-gotchas.md":"# Service Gotchas (Stable)\n\nPer-service summary of **non-intuitive required properties**, **common mistakes**, and **PE mappings**.\nOnly near-immutable patterns are included here. Dynamic values such as API version, SKU lists, and region are not included.\n\n---\n\n## 1. Required Properties (Deployment Failure or Functional Issues If Omitted)\n\n| Service | Required Property | Result If Omitted | Notes |\n|---------|------------------|-------------------|-------|\n| ADLS Gen2 | `isHnsEnabled: true` | Becomes regular Blob Storage. Cannot be reversed | `kind: 'StorageV2'` required |\n| Storage Account | No special characters/hyphens in name | Deployment failure | Lowercase+numbers only, 3-24 characters |\n| Foundry (AIServices) | `customSubDomainName: foundryName` | Cannot create Project, cannot change after creation → Must delete and recreate resource | Globally unique value |\n| Foundry (AIServices) | `allowProjectManagement: true` | Cannot create Foundry Project | `kind: 'AIServices'` |\n| Foundry (AIServices) | `identity: { type: 'SystemAssigned' }` | Project creation fails | |\n| Foundry Project | Must be created as a set with Foundry resource | Cannot use from portal | `accounts/projects` |\n| Key Vault | `enableRbacAuthorization: true` | Risk of mixed Access Policy usage | |\n| Key Vault | `enablePurgeProtection: true` | Required for production | |\n| Fabric Capacity | `administration.members` required | Deployment failure | Admin email |\n| PE Subnet | `privateEndpointNetworkPolicies: 'Disabled'` | PE deployment failure | |\n| PE DNS Zone | `registrationEnabled: false` (VNet Link) | Possible DNS conflict | |\n| PE Configuration | 3-component set (PE + DNS Zone + VNet Link + Zone Group) | DNS resolution fails even with PE present | |\n\n---\n\n## 2. PE groupId \u0026 DNS Zone Mapping (Key Services)\n\nThe mappings below are stable, but re-verify from the PE DNS integration document in `azure-dynamic-sources.md` when adding new services.\n\n| Service | groupId | Private DNS Zone |\n|---------|---------|-----------------|\n| Azure OpenAI / CognitiveServices | `account` | `privatelink.cognitiveservices.azure.com` |\n| ⚠️ (Foundry/AIServices additional) | `account` | `privatelink.openai.azure.com` ← **Both zones must be included in DNS Zone Group. OpenAI API DNS resolution fails if omitted** |\n| Azure AI Search | `searchService` | `privatelink.search.windows.net` |\n| Storage (Blob/ADLS) | `blob` | `privatelink.blob.core.windows.net` |\n| Storage (DFS/ADLS Gen2) | `dfs` | `privatelink.dfs.core.windows.net` |\n| Key Vault | `vault` | `privatelink.vaultcore.azure.net` |\n| Azure ML / AI Hub | `amlworkspace` | `privatelink.api.azureml.ms` |\n| Container Registry | `registry` | `privatelink.azurecr.io` |\n| Cosmos DB (SQL) | `Sql` | `privatelink.documents.azure.com` |\n| Azure Cache for Redis | `redisCache` | `privatelink.redis.cache.windows.net` |\n| Data Factory | `dataFactory` | `privatelink.datafactory.azure.net` |\n| API Management | `Gateway` | `privatelink.azure-api.net` |\n| Event Hub | `namespace` | `privatelink.servicebus.windows.net` |\n| Service Bus | `namespace` | `privatelink.servicebus.windows.net` |\n| Monitor (AMPLS) | ⚠️ Complex configuration — see below | ⚠️ Multiple DNS Zones required — see below |\n\n\u003e **ADLS Gen2 Note**: When `isHnsEnabled: true`, **both `blob` and `dfs` PEs are required**.\n\u003e - With only the `blob` PE, Blob API works, but Data Lake operations (file system creation, directory manipulation, `abfss://` protocol) will fail.\n\u003e - DFS PE: groupId `dfs`, DNS Zone `privatelink.dfs.core.windows.net`\n\u003e\n\u003e **⚠️ Azure Monitor Private Link (AMPLS) Note**: Azure Monitor cannot be configured with a single PE + single DNS Zone. It connects through Azure Monitor Private Link Scope (AMPLS), and all **5 DNS Zones** are required:\n\u003e - `privatelink.monitor.azure.com`\n\u003e - `privatelink.oms.opinsights.azure.com`\n\u003e - `privatelink.ods.opinsights.azure.com`\n\u003e - `privatelink.agentsvc.azure-automation.net`\n\u003e - `privatelink.blob.core.windows.net` (for Log Analytics data ingestion)\n\u003e\n\u003e This mapping is complex and subject to change, so always fetch and verify MS Docs when configuring Monitor PE:\n\u003e https://learn.microsoft.com/en-us/azure/azure-monitor/logs/private-link-configure\n\n---\n\n## 3. Common Mistakes Checklist\n\n| Item | ❌ Incorrect Example | ✅ Correct Example |\n|------|---------------------|-------------------|\n| ADLS Gen2 HNS | `isHnsEnabled` omitted or `false` | `isHnsEnabled: true` |\n| PE Subnet | Policy not set | `privateEndpointNetworkPolicies: 'Disabled'` |\n| DNS Zone Group | Only PE created | PE + DNS Zone + VNet Link + DNS Zone Group |\n| Foundry resource | `kind: 'OpenAI'` | `kind: 'AIServices'` + `allowProjectManagement: true` |\n| Foundry resource | `customSubDomainName` omitted | `customSubDomainName: foundryName` — Cannot change after creation |\n| Foundry Project | Only Foundry exists without Project | Must create as a set |\n| Key Vault auth | Access Policy | `enableRbacAuthorization: true` |\n| Public network | Not configured | `publicNetworkAccess: 'Disabled'` |\n| Storage name | `st-my-storage` | `stmystorage` or `st${uniqueString(...)}` |\n| API version | Copied from previous conversation/error | Verify latest stable from MS Docs |\n| Region | Hardcoded (`'eastus'`) | Pass as parameter (`param location`) |\n| Sensitive values | Plaintext in `.bicepparam` | `@secure()` + Key Vault reference |\n\n---\n\n## 4. Service Relationship Decision Rules\n\nDescribed as **default selection rules** rather than absolute determinations.\n\n### Foundry vs Azure OpenAI vs AI Hub\n\n```\nDefault rules:\n├─ AI/RAG workloads → Use Microsoft Foundry (kind: 'AIServices')\n│   ├─ Create Foundry resource + Foundry Project as a set\n│   └─ Model deployment is performed at the Foundry resource level (accounts/deployments)\n│\n├─ ML/open-source model training needed → Consider AI Hub (MachineLearningServices)\n│   └─ Only when the user explicitly requests it or features not supported in Foundry are needed\n│\n└─ Standalone Azure OpenAI resource →\n    Consider only when the user explicitly requests it or\n    official documentation requires a separate resource\n```\n\n\u003e These rules are a **default selection guide** reflecting current MS recommendations.\n\u003e Azure product relationships can change, so check MS Docs when uncertain.\n\n### Monitoring\n\n```\nDefault rules:\n├─ Foundry (AIServices) → Application Insights not required\n└─ AI Hub (MachineLearningServices) → Application Insights + Log Analytics required\n```\n","scripts/cli.py":"#!/usr/bin/env python3\n\"\"\"CLI for azure-architecture-autopilot diagram engine.\"\"\"\nimport argparse\nimport json\nimport sys\nimport os\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom generator import generate_diagram\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"Generate interactive Azure architecture diagrams\",\n        prog=\"azure-architecture-autopilot\"\n    )\n    parser.add_argument(\"-s\", \"--services\", help=\"Services JSON (string or file path)\")\n    parser.add_argument(\"-c\", \"--connections\", help=\"Connections JSON (string or file path)\")\n    parser.add_argument(\"-t\", \"--title\", default=\"Azure Architecture\", help=\"Diagram title\")\n    parser.add_argument(\"-o\", \"--output\", default=\"azure-architecture.html\", help=\"Output file path\")\n    parser.add_argument(\"-f\", \"--format\", choices=[\"html\", \"png\", \"both\"], default=\"html\",\n                        help=\"Output format: html (default), png, or both (html+png)\")\n    parser.add_argument(\"--vnet-info\", default=\"\", help=\"VNet CIDR info\")\n    parser.add_argument(\"--hierarchy\", default=\"\", help=\"Subscription/RG hierarchy JSON\")\n\n    args = parser.parse_args()\n\n    if not args.services or not args.connections:\n        parser.error(\"-s/--services and -c/--connections are required\")\n\n    services = _load_json(args.services, \"services\")\n    connections = _load_json(args.connections, \"connections\")\n    hierarchy = None\n    if args.hierarchy:\n        hierarchy = _load_json(args.hierarchy, \"hierarchy\")\n\n    services = _normalize_services(services)\n    connections = _normalize_connections(connections)\n\n    html = generate_diagram(\n        services=services,\n        connections=connections,\n        title=args.title,\n        vnet_info=args.vnet_info,\n        hierarchy=hierarchy,\n    )\n\n    # Determine output paths\n    out = Path(args.output)\n    html_path = out.with_suffix(\".html\")\n    png_path = out.with_suffix(\".png\")\n    svg_path = out.with_suffix(\".svg\")\n\n    if args.format in (\"html\", \"both\"):\n        html_path.write_text(html, encoding=\"utf-8\")\n        print(f\"HTML saved: {html_path}\")\n\n    if args.format in (\"png\", \"both\"):\n        # Write temp HTML then screenshot with puppeteer/playwright\n        tmp_html = html_path if args.format == \"both\" else Path(str(png_path) + \".tmp.html\")\n        if args.format != \"both\":\n            tmp_html.write_text(html, encoding=\"utf-8\")\n\n        success = _html_to_png(tmp_html, png_path)\n\n        if args.format != \"both\" and tmp_html.exists():\n            tmp_html.unlink()\n\n        if success:\n            print(f\"PNG saved: {png_path}\")\n        else:\n            print(f\"WARNING: PNG export failed. Install puppeteer (npm i puppeteer) for PNG support.\", file=sys.stderr)\n            print(f\"HTML saved instead: {html_path}\")\n            if not html_path.exists():\n                html_path.write_text(html, encoding=\"utf-8\")\n\n\ndef _html_to_png(html_path, png_path, width=1920, height=1080):\n    \"\"\"Convert HTML to PNG using puppeteer (Node.js).\"\"\"\n    node = shutil.which(\"node\")\n    if not node:\n        return False\n\n    # Try multiple puppeteer locations\n    script = f\"\"\"\nlet puppeteer;\nconst paths = [\n  'puppeteer',\n  process.env.TEMP + '/node_modules/puppeteer',\n  process.env.HOME + '/node_modules/puppeteer',\n  './node_modules/puppeteer'\n];\nfor (const p of paths) {{ try {{ puppeteer = require(p); break; }} catch(e) {{}} }}\nif (!puppeteer) {{ console.error('puppeteer not found'); process.exit(1); }}\n(async () =\u003e {{\n  const browser = await puppeteer.launch({{headless: 'new'}});\n  const page = await browser.newPage();\n  await page.setViewport({{width: {width}, height: {height}}});\n  await page.goto('file:///{html_path.resolve().as_posix()}', {{waitUntil: 'networkidle0'}});\n  await new Promise(r =\u003e setTimeout(r, 2000));\n  await page.screenshot({{path: '{png_path.resolve().as_posix()}'}});\n  await browser.close();\n}})();\n\"\"\"\n    try:\n        result = subprocess.run([node, \"-e\", script], capture_output=True, text=True, timeout=30)\n        return result.returncode == 0 and png_path.exists()\n    except (subprocess.TimeoutExpired, FileNotFoundError):\n        return False\n\n\ndef _load_json(value, name):\n    \"\"\"Load JSON from string or file path. Extracts named key from combined JSON if present.\"\"\"\n    data = None\n    if os.path.isfile(value):\n        with open(value, \"r\", encoding=\"utf-8\") as f:\n            data = json.load(f)\n    else:\n        try:\n            data = json.loads(value)\n        except json.JSONDecodeError as e:\n            print(f\"ERROR: Invalid JSON for --{name}: {e}\", file=sys.stderr)\n            sys.exit(1)\n\n    # If data is a dict with the named key, extract it (combined JSON file support)\n    if isinstance(data, dict) and name in data:\n        return data[name]\n    return data\n\n\ndef _normalize_services(services):\n    \"\"\"Normalize service fields for tolerance.\"\"\"\n    for svc in services:\n        if isinstance(svc.get(\"details\"), str):\n            svc[\"details\"] = [svc[\"details\"]]\n        if isinstance(svc.get(\"private\"), str):\n            val = svc[\"private\"].lower()\n            if val in (\"true\", \"1\", \"yes\", \"on\"):\n                svc[\"private\"] = True\n            elif val in (\"false\", \"0\", \"no\", \"off\"):\n                svc[\"private\"] = False\n            else:\n                # Log warning for invalid values\n                print(f\"WARNING: Invalid boolean value '{svc['private']}' for 'private' field. Defaulting to False.\", file=sys.stderr)\n                svc[\"private\"] = False\n    return services\n\n\ndef _normalize_connections(connections):\n    \"\"\"Normalize connection fields for tolerance.\"\"\"\n    for conn in connections:\n        if \"type\" not in conn:\n            conn[\"type\"] = \"default\"\n    return connections\n\n\nif __name__ == \"__main__\":\n    main()\n","scripts/generator.py":"#!/usr/bin/env python3\n\"\"\"\nAzure Interactive Architecture Diagram Generator v3\nGenerates interactive HTML diagrams with Azure official icons (Base64 inline).\n\"\"\"\n\nimport json\nfrom datetime import datetime\n\nfrom icons import get_icon_data_uri\n\n_HAS_OFFICIAL_ICONS = True\n# Azure service icons: SVG, colors + official icon key mapping\n# icon: 48x48 viewBox SVG path (fallback)\n# azure_icon_key: key in icons.py (official Azure icon)\nSERVICE_ICONS = {\n    \"openai\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"18\" fill=\"white\" font-weight=\"700\"\u003eAI\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"azure_openai\"\n    },\n    \"ai_foundry\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003crect x=\"12\" y=\"16\" width=\"10\" height=\"8\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"26\" y=\"16\" width=\"10\" height=\"8\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"12\" y=\"27\" width=\"24\" height=\"5\" rx=\"2\" fill=\"white\" opacity=\"0.6\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"ai_foundry\"\n    },\n    \"ai_hub\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"8\" fill=\"white\" opacity=\"0.9\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"4\" fill=\"#0078D4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"machine_learning\"\n    },\n    \"search\": {\n        \"icon_svg\": '\u003ccircle cx=\"20\" cy=\"20\" r=\"12\" fill=\"none\" stroke=\"#0078D4\" stroke-width=\"3.5\"/\u003e\u003cline x1=\"29\" y1=\"29\" x2=\"40\" y2=\"40\" stroke=\"#0078D4\" stroke-width=\"3.5\" stroke-linecap=\"round\"/\u003e\u003ccircle cx=\"20\" cy=\"20\" r=\"5\" fill=\"#0078D4\" opacity=\"0.3\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"cognitive_search\"\n    },\n    \"ai_search\": {\n        \"icon_svg\": '\u003ccircle cx=\"20\" cy=\"20\" r=\"12\" fill=\"none\" stroke=\"#0078D4\" stroke-width=\"3.5\"/\u003e\u003cline x1=\"29\" y1=\"29\" x2=\"40\" y2=\"40\" stroke=\"#0078D4\" stroke-width=\"3.5\" stroke-linecap=\"round\"/\u003e\u003ccircle cx=\"20\" cy=\"20\" r=\"5\" fill=\"#0078D4\" opacity=\"0.3\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"cognitive_search\"\n    },\n    \"aml\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 32 L20 18 L26 26 L32 14\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"machine_learning\"\n    },\n    \"storage\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\"/\u003e\u003crect x=\"8\" y=\"20\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\" opacity=\"0.7\"/\u003e\u003crect x=\"8\" y=\"32\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\" opacity=\"0.4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"storage_accounts\"\n    },\n    \"adls\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\"/\u003e\u003crect x=\"8\" y=\"20\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\" opacity=\"0.7\"/\u003e\u003crect x=\"8\" y=\"32\" width=\"32\" height=\"8\" rx=\"3\" fill=\"#0078D4\" opacity=\"0.4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"data_lake_storage_gen1\"\n    },\n    \"fabric\": {\n        \"icon_svg\": '\u003cpolygon points=\"24,6 42,18 42,34 24,46 6,34 6,18\" fill=\"#E8740C\" opacity=\"0.9\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"14\" fill=\"white\" font-weight=\"700\"\u003eF\u003c/text\u003e',\n        \"color\": \"#E8740C\", \"bg\": \"#FEF3E8\", \"category\": \"Data\",\n        \"azure_icon_key\": \"microsoft_fabric\"\n    },\n    \"synapse\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M15 24 L24 15 L33 24 L24 33 Z\" fill=\"white\" opacity=\"0.9\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_synapse_analytics\"\n    },\n    \"adf\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"12\" width=\"36\" height=\"24\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 24 L28 24 M24 18 L30 24 L24 30\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"data_factory\"\n    },\n    \"data_factory\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"12\" width=\"36\" height=\"24\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 24 L28 24 M24 18 L30 24 L24 30\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"data_factory\"\n    },\n    \"keyvault\": {\n        \"icon_svg\": '\u003crect x=\"10\" y=\"6\" width=\"28\" height=\"36\" rx=\"4\" fill=\"#E8A000\"/\u003e\u003ccircle cx=\"24\" cy=\"22\" r=\"6\" fill=\"white\"/\u003e\u003crect x=\"22\" y=\"26\" width=\"4\" height=\"10\" rx=\"1\" fill=\"white\"/\u003e',\n        \"color\": \"#E8A000\", \"bg\": \"#FEF7E0\", \"category\": \"Security\",\n        \"azure_icon_key\": \"key_vaults\"\n    },\n    \"kv\": {\n        \"icon_svg\": '\u003crect x=\"10\" y=\"6\" width=\"28\" height=\"36\" rx=\"4\" fill=\"#E8A000\"/\u003e\u003ccircle cx=\"24\" cy=\"22\" r=\"6\" fill=\"white\"/\u003e\u003crect x=\"22\" y=\"26\" width=\"4\" height=\"10\" rx=\"1\" fill=\"white\"/\u003e',\n        \"color\": \"#E8A000\", \"bg\": \"#FEF7E0\", \"category\": \"Security\",\n        \"azure_icon_key\": \"key_vaults\"\n    },\n    \"vnet\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"none\" stroke=\"#5C2D91\" stroke-width=\"2.5\"/\u003e\u003ccircle cx=\"16\" cy=\"18\" r=\"4\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"32\" cy=\"18\" r=\"4\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"24\" cy=\"32\" r=\"4\" fill=\"#5C2D91\"/\u003e\u003cline x1=\"16\" y1=\"18\" x2=\"32\" y2=\"18\" stroke=\"#5C2D91\" stroke-width=\"1.5\"/\u003e\u003cline x1=\"16\" y1=\"18\" x2=\"24\" y2=\"32\" stroke=\"#5C2D91\" stroke-width=\"1.5\"/\u003e\u003cline x1=\"32\" y1=\"18\" x2=\"24\" y2=\"32\" stroke=\"#5C2D91\" stroke-width=\"1.5\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"virtual_networks\"\n    },\n    \"pe\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"14\" fill=\"none\" stroke=\"#5C2D91\" stroke-width=\"2\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"6\" fill=\"#5C2D91\"/\u003e\u003cline x1=\"24\" y1=\"10\" x2=\"24\" y2=\"4\" stroke=\"#5C2D91\" stroke-width=\"2\"/\u003e\u003cline x1=\"24\" y1=\"38\" x2=\"24\" y2=\"44\" stroke=\"#5C2D91\" stroke-width=\"2\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"private_endpoints\"\n    },\n    \"nsg\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003cpath d=\"M18 20 L24 14 L30 20 M18 28 L24 34 L30 28\" stroke=\"white\" stroke-width=\"2\" fill=\"none\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"network_security_groups\"\n    },\n    \"acr\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"10\" width=\"32\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003crect x=\"14\" y=\"16\" width=\"20\" height=\"16\" rx=\"2\" fill=\"white\" opacity=\"0.3\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"12\" fill=\"white\" font-weight=\"600\"\u003eACR\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\"\n    },\n    \"aks\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#326CE5\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" fill=\"white\" font-weight=\"700\"\u003eK\u003c/text\u003e',\n        \"color\": \"#326CE5\", \"bg\": \"#EBF0FC\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"kubernetes_services\"\n    },\n    \"appservice\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"6\" fill=\"#0078D4\"/\u003e\u003cpolygon points=\"24,14 34,34 14,34\" fill=\"white\" opacity=\"0.9\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"app_services\"\n    },\n    \"appinsights\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"16\" fill=\"#773ADC\"/\u003e\u003cpath d=\"M16 28 L20 20 L24 24 L28 16 L32 22\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e',\n        \"color\": \"#773ADC\", \"bg\": \"#F0EAFA\", \"category\": \"Monitor\",\n        \"azure_icon_key\": \"application_insights\"\n    },\n    \"monitor\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"24\" rx=\"4\" fill=\"#773ADC\"/\u003e\u003cpath d=\"M14 28 L20 20 L26 24 L34 16\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e\u003crect x=\"14\" y=\"36\" width=\"20\" height=\"3\" rx=\"1\" fill=\"#773ADC\" opacity=\"0.5\"/\u003e',\n        \"color\": \"#773ADC\", \"bg\": \"#F0EAFA\", \"category\": \"Monitor\",\n        \"azure_icon_key\": \"monitor\"\n    },\n    \"vm\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"26\" rx=\"3\" fill=\"#0078D4\"/\u003e\u003crect x=\"10\" y=\"12\" width=\"28\" height=\"18\" rx=\"1\" fill=\"white\" opacity=\"0.2\"/\u003e\u003crect x=\"16\" y=\"36\" width=\"16\" height=\"4\" rx=\"1\" fill=\"#0078D4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"virtual_machine\"\n    },\n    \"bastion\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"6\" width=\"32\" height=\"36\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003crect x=\"14\" y=\"12\" width=\"20\" height=\"14\" rx=\"2\" fill=\"white\" opacity=\"0.3\"/\u003e\u003ccircle cx=\"24\" cy=\"34\" r=\"4\" fill=\"white\" opacity=\"0.7\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"bastions\"\n    },\n    \"jumpbox\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"14\" fill=\"white\" font-weight=\"600\"\u003eJB\u003c/text\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"virtual_machine\"\n    },\n    \"vpn\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"12\" width=\"36\" height=\"24\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003cpath d=\"M16 24 L24 16 L32 24 L24 32 Z\" fill=\"white\" opacity=\"0.8\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"virtual_network_gateways\"\n    },\n    \"user\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"16\" r=\"8\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M10 42 Q10 30 24 30 Q38 30 38 42\" fill=\"#0078D4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"External\"\n    },\n    \"app\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"6\" width=\"32\" height=\"36\" rx=\"6\" fill=\"#666\"/\u003e\u003crect x=\"14\" y=\"12\" width=\"20\" height=\"20\" rx=\"2\" fill=\"white\" opacity=\"0.3\"/\u003e\u003ccircle cx=\"24\" cy=\"40\" r=\"2\" fill=\"white\" opacity=\"0.7\"/\u003e',\n        \"color\": \"#666666\", \"bg\": \"#F5F5F5\", \"category\": \"External\"\n    },\n    \"default\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"16\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"14\" fill=\"white\" font-weight=\"600\"\u003e?\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Azure\"\n    },\n    \"cdn\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eCDN\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Network\",\n        \"azure_icon_key\": \"cdn_profiles\"\n    },\n    \"event_hub\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\" font-weight=\"700\"\u003eEvent\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\"\u003eHub\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"event_hubs\"\n    },\n    \"redis\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#D83B01\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eRedis\u003c/text\u003e',\n        \"color\": \"#D83B01\", \"bg\": \"#FEF0E8\", \"category\": \"Data\",\n        \"azure_icon_key\": \"cache_redis\"\n    },\n    \"devops\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\" font-weight=\"700\"\u003eDev\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\"\u003eOps\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"DevOps\",\n        \"azure_icon_key\": \"azure_devops\"\n    },\n    \"acr\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"10\" width=\"32\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eACR\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"container_registries\"\n    },\n    \"container_registry\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"10\" width=\"32\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eACR\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"container_registries\"\n    },\n    \"app_gateway\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\" font-weight=\"700\"\u003eApp\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\"\u003eGW\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Network\",\n        \"azure_icon_key\": \"application_gateways\"\n    },\n    \"iot_hub\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\" font-weight=\"700\"\u003eIoT\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\"\u003eHub\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"IoT\",\n        \"azure_icon_key\": \"iot_hub\"\n    },\n    \"stream_analytics\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\" font-weight=\"700\"\u003eStream\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\"\u003eAnalytics\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"stream_analytics_jobs\"\n    },\n    \"vpn_gateway\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"12\" width=\"36\" height=\"24\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003cpath d=\"M16 24 L24 16 L32 24 L24 32 Z\" fill=\"white\" opacity=\"0.8\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"virtual_network_gateways\"\n    },\n    \"front_door\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\" font-weight=\"700\"\u003eFront\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\"\u003eDoor\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Network\",\n        \"azure_icon_key\": \"front_door_and_cdn_profiles\"\n    },\n    \"ai_hub\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"8\" fill=\"white\" opacity=\"0.9\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"4\" fill=\"#0078D4\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"ai_studio\"\n    },\n    \"firewall\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#E8A000\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\" font-weight=\"700\"\u003eFire\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"7\" fill=\"white\"\u003ewall\u003c/text\u003e',\n        \"color\": \"#E8A000\", \"bg\": \"#FFF8E1\", \"category\": \"Network\",\n        \"azure_icon_key\": \"firewalls\"\n    },\n    \"document_intelligence\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\" font-weight=\"700\"\u003eDoc\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\"\u003eIntel\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"form_recognizer\"\n    },\n    \"form_recognizer\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\" font-weight=\"700\"\u003eDoc\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\"\u003eIntel\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"AI\",\n        \"azure_icon_key\": \"form_recognizer\"\n    },\n    \"databricks\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"6\" fill=\"#FF3621\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" fill=\"white\" font-weight=\"700\"\u003eDB\u003c/text\u003e',\n        \"color\": \"#FF3621\", \"bg\": \"#FFF0EE\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_databricks\"\n    },\n    \"sql_server\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"11\" fill=\"white\" font-weight=\"700\"\u003eSQL\u003c/text\u003e\u003crect x=\"12\" y=\"28\" width=\"24\" height=\"8\" rx=\"2\" fill=\"white\" opacity=\"0.3\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"sql_server\"\n    },\n    \"sql_database\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"11\" fill=\"white\" font-weight=\"700\"\u003eSQL\u003c/text\u003e\u003crect x=\"12\" y=\"28\" width=\"24\" height=\"8\" rx=\"2\" fill=\"white\" opacity=\"0.3\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"sql_database\"\n    },\n    \"cosmos_db\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\" font-weight=\"700\"\u003eCosmos\u003c/text\u003e\u003ctext x=\"24\" y=\"33\" text-anchor=\"middle\" font-size=\"9\" fill=\"white\"\u003eDB\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_cosmos_db\"\n    },\n    \"app_service\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"28\" rx=\"6\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"11\" fill=\"white\" font-weight=\"700\"\u003eApp\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"app_services\"\n    },\n    \"aks\": {\n        \"icon_svg\": '\u003cpolygon points=\"24,4 44,20 38,44 10,44 4,20\" fill=\"#326CE5\" stroke=\"#fff\" stroke-width=\"1\"/\u003e\u003ctext x=\"24\" y=\"30\" text-anchor=\"middle\" font-size=\"11\" fill=\"white\" font-weight=\"700\"\u003eK8s\u003c/text\u003e',\n        \"color\": \"#326CE5\", \"bg\": \"#EBF0FA\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"kubernetes_services\"\n    },\n    \"function_app\": {\n        \"icon_svg\": '\u003cpolygon points=\"24,6 42,42 6,42\" fill=\"#F0AD4E\"/\u003e\u003ctext x=\"24\" y=\"36\" text-anchor=\"middle\" font-size=\"14\" fill=\"white\" font-weight=\"700\"\u003eƒ\u003c/text\u003e',\n        \"color\": \"#F0AD4E\", \"bg\": \"#FFF8ED\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"function_apps\"\n    },\n    \"synapse\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"22\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\" font-weight=\"700\"\u003eSyn\u003c/text\u003e\u003ctext x=\"24\" y=\"32\" text-anchor=\"middle\" font-size=\"8\" fill=\"white\"\u003eapse\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_synapse_analytics\"\n    },\n    \"log_analytics\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eLog\u003c/text\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EDF7\", \"category\": \"Monitoring\",\n        \"azure_icon_key\": \"log_analytics_workspaces\"\n    },\n    \"app_insights\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#5C2D91\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eAI\u003c/text\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EDF7\", \"category\": \"Monitoring\",\n        \"azure_icon_key\": \"application_insights\"\n    },\n    \"nsg\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"#E8A000\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eNSG\u003c/text\u003e',\n        \"color\": \"#E8A000\", \"bg\": \"#FFF8E1\", \"category\": \"Network\",\n        \"azure_icon_key\": \"network_security_groups\"\n    },\n    \"apim\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 20 L32 20 M16 28 L32 28 M24 14 L24 34\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"api_management_services\"\n    },\n    \"api_management\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 20 L32 20 M16 28 L32 28 M24 14 L24 34\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"api_management_services\"\n    },\n    \"service_bus\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"10\" width=\"36\" height=\"28\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 24 L22 24 M26 24 L34 24\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"4\" fill=\"white\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"azure_service_bus\"\n    },\n    \"logic_apps\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 18 L24 28 L34 18\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"logic_apps\"\n    },\n    \"logic_app\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 18 L24 28 L34 18\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"logic_apps\"\n    },\n    \"event_grid\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ccircle cx=\"16\" cy=\"18\" r=\"3\" fill=\"white\"/\u003e\u003ccircle cx=\"32\" cy=\"18\" r=\"3\" fill=\"white\"/\u003e\u003ccircle cx=\"16\" cy=\"30\" r=\"3\" fill=\"white\"/\u003e\u003ccircle cx=\"32\" cy=\"30\" r=\"3\" fill=\"white\"/\u003e\u003cline x1=\"16\" y1=\"18\" x2=\"32\" y2=\"30\" stroke=\"white\" stroke-width=\"1.5\"/\u003e\u003cline x1=\"32\" y1=\"18\" x2=\"16\" y2=\"30\" stroke=\"white\" stroke-width=\"1.5\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"event_grid_topics\"\n    },\n    \"container_apps\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003crect x=\"12\" y=\"14\" width=\"10\" height=\"10\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"26\" y=\"14\" width=\"10\" height=\"10\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"12\" y=\"28\" width=\"24\" height=\"6\" rx=\"2\" fill=\"white\" opacity=\"0.6\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"container_apps_environments\"\n    },\n    \"container_app\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003crect x=\"12\" y=\"14\" width=\"10\" height=\"10\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"26\" y=\"14\" width=\"10\" height=\"10\" rx=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003crect x=\"12\" y=\"28\" width=\"24\" height=\"6\" rx=\"2\" fill=\"white\" opacity=\"0.6\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"container_apps_environments\"\n    },\n    \"postgresql\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003ePG\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_database_postgresql_server\"\n    },\n    \"mysql\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eMy\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_database_mysql_server\"\n    },\n    \"load_balancer\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#5C2D91\"/\u003e\u003cpath d=\"M16 18 L32 18 M16 24 L32 24 M16 30 L32 30\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"load_balancers\"\n    },\n    \"nat_gateway\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eNAT\u003c/text\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"nat\"\n    },\n    \"expressroute\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#5C2D91\"/\u003e\u003cpath d=\"M14 24 L34 24\" stroke=\"white\" stroke-width=\"3\" fill=\"none\" stroke-linecap=\"round\"/\u003e\u003ccircle cx=\"14\" cy=\"24\" r=\"4\" fill=\"white\"/\u003e\u003ccircle cx=\"34\" cy=\"24\" r=\"4\" fill=\"white\"/\u003e',\n        \"color\": \"#5C2D91\", \"bg\": \"#F3EEF9\", \"category\": \"Network\",\n        \"azure_icon_key\": \"expressroute_circuits\"\n    },\n    \"sentinel\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M24 12 L24 24 L32 28\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e\u003ccircle cx=\"24\" cy=\"24\" r=\"3\" fill=\"white\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Security\",\n        \"azure_icon_key\": \"azure_sentinel\"\n    },\n    \"data_explorer\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 30 L20 18 L26 26 L34 14\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_data_explorer_clusters\"\n    },\n    \"kusto\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M14 30 L20 18 L26 26 L34 14\" stroke=\"white\" stroke-width=\"2.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Data\",\n        \"azure_icon_key\": \"azure_data_explorer_clusters\"\n    },\n    \"signalr\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 20 Q24 12 32 20 M16 28 Q24 36 32 28\" stroke=\"white\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"signalr\"\n    },\n    \"notification_hub\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M18 16 L24 12 L30 16 L30 28 L18 28 Z\" stroke=\"white\" stroke-width=\"2\" fill=\"white\" opacity=\"0.9\"/\u003e\u003ccircle cx=\"24\" cy=\"32\" r=\"3\" fill=\"white\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Integration\",\n        \"azure_icon_key\": \"notification_hubs\"\n    },\n    \"spring_apps\": {\n        \"icon_svg\": '\u003ccircle cx=\"24\" cy=\"24\" r=\"18\" fill=\"#6DB33F\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003e🌱\u003c/text\u003e',\n        \"color\": \"#6DB33F\", \"bg\": \"#EFF8E8\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"azure_spring_apps\"\n    },\n    \"static_web_app\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ctext x=\"24\" y=\"28\" text-anchor=\"middle\" font-size=\"10\" fill=\"white\" font-weight=\"700\"\u003eSWA\u003c/text\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Compute\",\n        \"azure_icon_key\": \"static_apps\"\n    },\n    \"digital_twins\": {\n        \"icon_svg\": '\u003crect x=\"6\" y=\"8\" width=\"36\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003ccircle cx=\"18\" cy=\"20\" r=\"5\" fill=\"white\" opacity=\"0.9\"/\u003e\u003ccircle cx=\"30\" cy=\"20\" r=\"5\" fill=\"white\" opacity=\"0.9\"/\u003e\u003cline x1=\"18\" y1=\"25\" x2=\"18\" y2=\"34\" stroke=\"white\" stroke-width=\"2\"/\u003e\u003cline x1=\"30\" y1=\"25\" x2=\"30\" y2=\"34\" stroke=\"white\" stroke-width=\"2\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"IoT\",\n        \"azure_icon_key\": \"digital_twins\"\n    },\n    \"backup\": {\n        \"icon_svg\": '\u003crect x=\"8\" y=\"8\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#0078D4\"/\u003e\u003cpath d=\"M16 28 L24 16 L32 28 Z\" stroke=\"white\" stroke-width=\"2\" fill=\"white\" opacity=\"0.8\"/\u003e',\n        \"color\": \"#0078D4\", \"bg\": \"#E8F4FD\", \"category\": \"Management\",\n        \"azure_icon_key\": \"backup_vault\"\n    },\n}\n\nCONNECTION_STYLES = {\n    \"api\":      {\"color\": \"#0078D4\", \"dash\": \"0\"},\n    \"data\":     {\"color\": \"#0F9D58\", \"dash\": \"0\"},\n    \"security\": {\"color\": \"#E8A000\", \"dash\": \"5,5\"},\n    \"private\":  {\"color\": \"#5C2D91\", \"dash\": \"3,3\"},\n    \"network\":  {\"color\": \"#5C2D91\", \"dash\": \"5,5\"},\n    \"default\":  {\"color\": \"#999999\", \"dash\": \"0\"},\n}\n\n\n_TYPE_ALIASES = {\n    # Azure ARM resource names → canonical diagram type\n    # Network\n    \"private_endpoints\": \"pe\", \"private_endpoint\": \"pe\",\n    \"virtual_networks\": \"vnet\", \"virtual_network\": \"vnet\",\n    \"network_security_groups\": \"nsg\", \"network_security_group\": \"nsg\",\n    \"bastion_hosts\": \"bastion\", \"bastion_host\": \"bastion\",\n    \"application_gateways\": \"app_gateway\", \"application_gateway\": \"app_gateway\",\n    \"front_doors\": \"front_door\", \"front_door_and_cdn_profiles\": \"front_door\",\n    \"virtual_network_gateways\": \"vpn\", \"vpn_gateways\": \"vpn\",\n    \"load_balancers\": \"load_balancer\",\n    \"nat_gateways\": \"nat_gateway\",\n    \"expressroute_circuits\": \"expressroute\",\n    \"firewalls\": \"firewall\",\n    \"cdn_profiles\": \"cdn\",\n    # Data\n    \"data_factories\": \"adf\", \"data_factory\": \"adf\",\n    \"storage_accounts\": \"storage\", \"storage_account\": \"storage\",\n    \"data_lake\": \"adls\", \"adls_gen2\": \"adls\", \"data_lake_storage\": \"adls\",\n    \"fabric_capacities\": \"fabric\", \"fabric_capacity\": \"fabric\", \"microsoft_fabric\": \"fabric\",\n    \"synapse_workspaces\": \"synapse\", \"synapse_workspace\": \"synapse\", \"synapse_analytics\": \"synapse\",\n    \"cosmos\": \"cosmos_db\", \"cosmosdb\": \"cosmos_db\", \"documentdb\": \"cosmos_db\",\n    \"sql_databases\": \"sql_database\", \"sql_db\": \"sql_database\",\n    \"sql_servers\": \"sql_server\",\n    \"redis_caches\": \"redis\", \"redis_cache\": \"redis\", \"cache_redis\": \"redis\",\n    \"stream_analytics_jobs\": \"stream_analytics\",\n    \"databricks_workspaces\": \"databricks\",\n    \"data_explorer_clusters\": \"data_explorer\", \"azure_data_explorer\": \"data_explorer\",\n    \"postgresql_server\": \"postgresql\", \"postgresql_servers\": \"postgresql\",\n    \"mysql_server\": \"mysql\", \"mysql_servers\": \"mysql\",\n    # AI\n    \"cognitive_services\": \"ai_foundry\", \"ai_services\": \"ai_foundry\", \"foundry\": \"ai_foundry\",\n    \"azure_openai\": \"openai\",\n    \"cognitive_search\": \"search\", \"search_services\": \"search\", \"search_service\": \"search\",\n    \"machine_learning\": \"aml\", \"ml\": \"aml\", \"machine_learning_workspaces\": \"aml\",\n    \"form_recognizers\": \"document_intelligence\",\n    \"ai_studio\": \"ai_hub\", \"foundry_project\": \"ai_hub\",\n    # Security\n    \"key_vault\": \"keyvault\", \"key_vaults\": \"keyvault\",\n    \"sentinel\": \"sentinel\", \"azure_sentinel\": \"sentinel\",\n    # Compute\n    \"virtual_machines\": \"vm\", \"virtual_machine\": \"vm\",\n    \"app_services\": \"appservice\", \"web_apps\": \"appservice\", \"web_app\": \"appservice\",\n    \"function_apps\": \"function_app\", \"functions\": \"function_app\",\n    \"kubernetes_services\": \"aks\", \"managed_clusters\": \"aks\", \"kubernetes\": \"aks\",\n    \"container_registries\": \"acr\",\n    \"container_apps_environments\": \"container_apps\",\n    \"spring_apps\": \"spring_apps\", \"azure_spring_apps\": \"spring_apps\",\n    \"static_apps\": \"static_web_app\", \"static_web_apps\": \"static_web_app\",\n    # Integration\n    \"event_hubs\": \"event_hub\",\n    \"event_grid_topics\": \"event_grid\", \"event_grid_domains\": \"event_grid\",\n    \"api_management_services\": \"apim\",\n    \"service_bus_namespaces\": \"service_bus\",\n    \"logic_app\": \"logic_apps\",\n    \"notification_hubs\": \"notification_hub\",\n    # Monitoring\n    \"log_analytics_workspaces\": \"log_analytics\",\n    \"application_insights\": \"appinsights\", \"app_insight\": \"appinsights\",\n    # IoT\n    \"iot_hubs\": \"iot_hub\",\n    # Management\n    \"backup_vaults\": \"backup\", \"backup_vault\": \"backup\",\n}\n\ndef get_service_info(svc_type: str) -\u003e dict:\n    t = svc_type.lower().replace(\"-\", \"_\").replace(\" \", \"_\")\n    t = _TYPE_ALIASES.get(t, t)\n    info = SERVICE_ICONS.get(t, SERVICE_ICONS[\"default\"]).copy()\n    # Add official Azure icon data URI if available\n    azure_key = info.get(\"azure_icon_key\", t)\n    icon_uri = get_icon_data_uri(azure_key)\n    info[\"icon_data_uri\"] = icon_uri\n    return info\n\n\ndef generate_html(services: list, connections: list, title: str, vnet_info: str = \"\", hierarchy: list = None) -\u003e str:\n    def _norm(t):\n        t = t.lower().replace(\"-\", \"_\").replace(\" \", \"_\")\n        return _TYPE_ALIASES.get(t, t)\n\n    nodes_js = json.dumps([{\n        \"id\": svc[\"id\"],\n        \"name\": svc[\"name\"],\n        \"type\": _norm(svc.get(\"type\", \"default\")),\n        \"sku\": svc.get(\"sku\", \"\"),\n        \"private\": svc.get(\"private\", False),\n        \"details\": svc.get(\"details\", []),\n        \"subscription\": svc.get(\"subscription\", \"\"),\n        \"resourceGroup\": svc.get(\"resourceGroup\", \"\"),\n        \"icon_svg\": get_service_info(svc.get(\"type\", \"default\"))[\"icon_svg\"],\n        \"icon_data_uri\": get_service_info(svc.get(\"type\", \"default\")).get(\"icon_data_uri\", \"\"),\n        \"color\": get_service_info(svc.get(\"type\", \"default\"))[\"color\"],\n        \"bg\": get_service_info(svc.get(\"type\", \"default\"))[\"bg\"],\n        \"category\": get_service_info(svc.get(\"type\", \"default\"))[\"category\"],\n    } for svc in services], ensure_ascii=False)\n\n    hierarchy_js = json.dumps(hierarchy or [], ensure_ascii=False)\n\n    edges_js = json.dumps([{\n        \"from\": conn[\"from\"],\n        \"to\": conn[\"to\"],\n        \"label\": conn.get(\"label\", \"\"),\n        \"type\": conn.get(\"type\", \"default\"),\n        \"color\": CONNECTION_STYLES.get(conn.get(\"type\", \"default\"), CONNECTION_STYLES[\"default\"])[\"color\"],\n        \"dash\": CONNECTION_STYLES.get(conn.get(\"type\", \"default\"), CONNECTION_STYLES[\"default\"])[\"dash\"],\n    } for conn in connections], ensure_ascii=False)\n\n    pe_count = sum(1 for s in services if _norm(s.get(\"type\", \"default\")) == \"pe\")\n    svc_count = len(services) - pe_count\n    generated_at = datetime.now().strftime(\"%Y-%m-%d %H:%M\")\n    vnet_info_js = json.dumps(vnet_info, ensure_ascii=False)\n\n    html = f\"\"\"\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"ko\"\u003e\n\u003chead\u003e\n\u003cmeta charset=\"UTF-8\"\u003e\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n\u003ctitle\u003e{title}\u003c/title\u003e\n\u003cstyle\u003e\n  @import url('https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600;700\u0026family=Inter:wght@400;500;600\u0026display=swap');\n  * {{ box-sizing: border-box; margin: 0; padding: 0; }}\n  body {{ font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif; background: #f3f2f1; color: #323130; }}\n\n  .header {{\n    background: white; border-bottom: 1px solid #edebe9;\n    padding: 12px 24px; display: flex; align-items: center; gap: 14px;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.06);\n  }}\n  .header-icon {{\n    width: 32px; height: 32px; border-radius: 4px;\n    background: linear-gradient(135deg, #0078D4, #00BCF2);\n    display: flex; align-items: center; justify-content: center;\n  }}\n  .header-icon svg {{ width: 20px; height: 20px; }}\n  .header h1 {{ font-size: 15px; font-weight: 600; color: #201f1e; }}\n  .header .meta {{ font-size: 11px; color: #a19f9d; }}\n  .header-right {{ margin-left: auto; display: flex; gap: 16px; align-items: center; }}\n  .stat {{ font-size: 11px; color: #605e5c; }}\n  .stat b {{ color: #323130; }}\n\n  .container {{ display: flex; height: calc(100vh - 56px); }}\n\n  .canvas-area {{\n    flex: 1; position: relative; overflow: hidden;\n    background: white;\n    background-image:\n      linear-gradient(#faf9f8 1px, transparent 1px),\n      linear-gradient(90deg, #faf9f8 1px, transparent 1px);\n    background-size: 24px 24px;\n  }}\n  #canvas {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; }}\n\n  .toolbar {{\n    position: absolute; top: 10px; left: 10px;\n    display: flex; gap: 1px; z-index: 10;\n    background: white; border: 1px solid #edebe9; border-radius: 6px;\n    padding: 2px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n  }}\n  .tool-btn {{\n    background: transparent; border: none; border-radius: 4px;\n    padding: 5px 10px; font-size: 11px; cursor: pointer; color: #605e5c;\n    font-family: inherit; transition: all 0.1s;\n  }}\n  .tool-btn:hover {{ background: #f3f2f1; color: #323130; }}\n  .tool-sep {{ width: 1px; background: #edebe9; margin: 3px 1px; }}\n\n  .zoom-indicator {{\n    position: absolute; top: 10px; right: 286px;\n    background: white; border: 1px solid #edebe9; border-radius: 4px;\n    padding: 3px 8px; font-size: 10px; color: #a19f9d; z-index: 10;\n  }}\n\n  /* ── Sidebar ── */\n  .sidebar {{\n    width: 272px; background: #faf9f8; border-left: 1px solid #edebe9;\n    overflow-y: auto; display: flex; flex-direction: column;\n  }}\n  .sidebar::-webkit-scrollbar {{ width: 3px; }}\n  .sidebar::-webkit-scrollbar-thumb {{ background: #c8c6c4; border-radius: 3px; }}\n\n  .sidebar-header {{\n    padding: 12px 14px; border-bottom: 1px solid #edebe9;\n    font-weight: 600; font-size: 12px; color: #605e5c;\n    position: sticky; top: 0; background: #faf9f8; z-index: 1;\n  }}\n  .cat-label {{\n    padding: 10px 14px 4px; font-size: 10px; color: #a19f9d;\n    font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;\n  }}\n  .service-card {{\n    margin: 2px 6px; border: 1px solid #edebe9; border-radius: 6px;\n    overflow: hidden; cursor: pointer; transition: all 0.1s;\n    background: white;\n  }}\n  .service-card:hover {{ border-color: #c8c6c4; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }}\n  .service-card.selected {{ border-color: #0078D4; box-shadow: 0 0 0 1px #0078D4; }}\n  .service-card-header {{\n    padding: 7px 10px; display: flex; align-items: center; gap: 8px;\n  }}\n  .sc-icon {{ width: 28px; height: 28px; flex-shrink: 0; }}\n  .sc-icon svg {{ width: 28px; height: 28px; }}\n  .service-name {{ font-size: 12px; font-weight: 600; color: #323130; }}\n  .service-sku {{ font-size: 10px; color: #a19f9d; }}\n  .service-card-body {{ padding: 2px 10px 6px; }}\n  .service-detail {{ font-size: 10px; color: #605e5c; padding: 1px 0; }}\n  .service-detail::before {{ content: \"› \"; color: #a19f9d; }}\n  .private-badge {{\n    font-size: 9px; background: #f3eef9; color: #5C2D91;\n    border-radius: 3px; padding: 1px 5px; margin-left: auto;\n    border: 1px solid #e0d4f5;\n  }}\n\n  .legend {{\n    padding: 10px 14px; border-top: 1px solid #edebe9; margin-top: auto;\n  }}\n  .legend-title {{ font-size: 10px; font-weight: 600; color: #a19f9d; margin-bottom: 5px; }}\n  .legend-item {{ display: flex; align-items: center; gap: 6px; font-size: 10px; color: #605e5c; margin-bottom: 2px; }}\n  .legend-line {{ width: 18px; height: 2px; border-radius: 1px; }}\n  .legend-line-dash {{ width: 18px; height: 0; border-top: 2px dashed; }}\n\n  /* ── SVG styles ── */\n  .node {{ cursor: grab; pointer-events: all; }}\n  .node:active {{ cursor: grabbing; }}\n  .node .node-bg {{ pointer-events: all; }}\n  .node.selected .node-bg {{ stroke: #0078D4; stroke-width: 2.5; }}\n  .node.selected {{ filter: drop-shadow(0 0 6px rgba(0,120,212,0.4)); }}\n\n  /* ── Edge highlight on node select ── */\n  .edge-path {{ transition: opacity 0.2s, stroke-width 0.2s; }}\n  .edge-label {{ transition: opacity 0.2s; }}\n  .edge-path.highlight {{ opacity: 1 !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(0,120,212,0.5)); }}\n  .edge-path.dimmed {{ opacity: 0.1 !important; }}\n  .edge-label.highlight {{ opacity: 1 !important; font-weight: 700; }}\n  .edge-label.dimmed {{ opacity: 0.15 !important; }}\n  .edge-label-bg.highlight {{ stroke: #0078D4 !important; stroke-width: 1.5 !important; }}\n  .edge-label-bg.dimmed {{ opacity: 0.15 !important; }}\n  .node.dimmed {{ opacity: 0.25; transition: opacity 0.2s; }}\n\n  .subnet-rect {{\n    rx: 6; ry: 6;\n  }}\n  .subnet-label {{\n    font-size: 11px; font-weight: 600; font-family: 'Segoe UI', sans-serif;\n  }}\n\n  .status-bar {{\n    position: absolute; bottom: 10px; left: 10px;\n    background: white; border: 1px solid #edebe9; border-radius: 4px;\n    padding: 4px 10px; font-size: 10px; color: #a19f9d;\n    box-shadow: 0 1px 4px rgba(0,0,0,0.06);\n  }}\n\n  .tooltip {{\n    position: absolute; background: white; color: #323130;\n    border: 1px solid #edebe9; padding: 8px 12px;\n    border-radius: 6px; font-size: 11px; pointer-events: none;\n    white-space: nowrap; z-index: 100; display: none;\n    box-shadow: 0 4px 16px rgba(0,0,0,0.12);\n  }}\n  .tooltip strong {{ color: #201f1e; }}\n  .tooltip-detail {{ color: #605e5c; margin-top: 1px; font-size: 10px; }}\n\u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n\u003cdiv class=\"header\"\u003e\n  \u003cdiv class=\"header-icon\"\u003e\n    \u003csvg viewBox=\"0 0 24 24\"\u003e\u003cpath d=\"M12 2L2 7v10l10 5 10-5V7L12 2z\" fill=\"white\" opacity=\"0.9\"/\u003e\u003c/svg\u003e\n  \u003c/div\u003e\n  \u003cdiv\u003e\n    \u003ch1\u003e{title}\u003c/h1\u003e\n    \u003cdiv class=\"meta\"\u003eAzure Architecture \u0026middot; {generated_at}\u003c/div\u003e\n  \u003c/div\u003e\n  \u003cdiv class=\"header-right\"\u003e\n    \u003cdiv class=\"stat\"\u003e\u003cb\u003e{svc_count}\u003c/b\u003e Services\u003c/div\u003e\n    \u003cdiv class=\"stat\"\u003e\u003cb\u003e{pe_count}\u003c/b\u003e Private Endpoints\u003c/div\u003e\n    \u003cdiv class=\"stat\"\u003e\u003cb\u003e{len(connections)}\u003c/b\u003e Connections\u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n\u003cdiv class=\"container\"\u003e\n  \u003cdiv class=\"canvas-area\"\u003e\n    \u003cdiv class=\"toolbar\"\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"fitToScreen()\"\u003eFit\u003c/button\u003e\n      \u003cdiv class=\"tool-sep\"\u003e\u003c/div\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"zoomIn()\"\u003e+\u003c/button\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"zoomOut()\"\u003e\u0026minus;\u003c/button\u003e\n      \u003cdiv class=\"tool-sep\"\u003e\u003c/div\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"textBigger()\" title=\"Bigger text\" style=\"font-size:13px;\"\u003eA+\u003c/button\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"textSmaller()\" title=\"Smaller text\" style=\"font-size:10px;\"\u003eA\u0026minus;\u003c/button\u003e\n      \u003cdiv class=\"tool-sep\"\u003e\u003c/div\u003e\n      \u003cbutton class=\"tool-btn\" onclick=\"downloadPNG()\" title=\"Download PNG\"\u003e\u0026#128247; PNG\u003c/button\u003e\n    \u003c/div\u003e\n    \u003cdiv class=\"zoom-indicator\" id=\"zoom-level\"\u003e100%\u003c/div\u003e\n    \u003csvg id=\"canvas\"\u003e\n      \u003cdefs\u003e\n        \u003cmarker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"\u003e\n          \u003cpath d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" opacity=\"0.7\"/\u003e\n        \u003c/marker\u003e\n        \u003cmarker id=\"arr-data\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"\u003e\n          \u003cpath d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" opacity=\"0.7\"/\u003e\n        \u003c/marker\u003e\n        \u003cmarker id=\"arr-sec\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"\u003e\n          \u003cpath d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" opacity=\"0.7\"/\u003e\n        \u003c/marker\u003e\n        \u003cmarker id=\"arr-pe\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"\u003e\n          \u003cpath d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" opacity=\"0.7\"/\u003e\n        \u003c/marker\u003e\n        \u003cfilter id=\"shadow\"\u003e\n          \u003cfeDropShadow dx=\"0\" dy=\"1\" stdDeviation=\"2\" flood-opacity=\"0.08\"/\u003e\n        \u003c/filter\u003e\n      \u003c/defs\u003e\n      \u003cg id=\"diagram-root\"\u003e\u003c/g\u003e\n    \u003c/svg\u003e\n    \u003cdiv id=\"tooltip\" class=\"tooltip\"\u003e\u003c/div\u003e\n    \u003cdiv class=\"status-bar\"\u003eDrag nodes \u0026middot; Scroll to zoom \u0026middot; Drag empty space to pan\u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003cdiv class=\"sidebar\"\u003e\n    \u003cdiv class=\"sidebar-header\"\u003eResources\u003c/div\u003e\n    \u003cdiv id=\"service-list\"\u003e\u003c/div\u003e\n    \u003cdiv class=\"legend\"\u003e\n      \u003cdiv class=\"legend-title\"\u003eConnection Types\u003c/div\u003e\n      \u003cdiv class=\"legend-item\"\u003e\u003cdiv class=\"legend-line\" style=\"background:#0078D4;\"\u003e\u003c/div\u003e API\u003c/div\u003e\n      \u003cdiv class=\"legend-item\"\u003e\u003cdiv class=\"legend-line\" style=\"background:#0F9D58;\"\u003e\u003c/div\u003e Data\u003c/div\u003e\n      \u003cdiv class=\"legend-item\"\u003e\u003cdiv class=\"legend-line-dash\" style=\"border-color:#E8A000;\"\u003e\u003c/div\u003e Security\u003c/div\u003e\n      \u003cdiv class=\"legend-item\"\u003e\u003cdiv class=\"legend-line-dash\" style=\"border-color:#5C2D91;\"\u003e\u003c/div\u003e Private Endpoint\u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n\u003cscript\u003e\nconst NODES = {nodes_js};\nconst EDGES = {edges_js};\nconst VNET_INFO = {vnet_info_js};\nconst HIERARCHY = {hierarchy_js};\n\n// ── Node sizing ──\nconst SVC_W = 180, SVC_H = 120;  // service node (icon above, name below) — 20% larger\nconst PE_W = 120, PE_H = 84;     // pe node (smaller) — 20% larger\nconst GAP = 40;\n\n// ── Layout: Category Group Box style ──\n// Each category gets a labeled box, services arranged in a grid inside.\n// Groups arranged in 2D: main service groups on top, bottom groups below.\n// PE nodes in a separate PE subnet group.\n\nconst positions = {{}};\nconst useRgLayout = HIERARCHY.length \u003e 0 \u0026\u0026 NODES.some(n =\u003e n.resourceGroup);\nconst peNodes = useRgLayout ? [] : NODES.filter(n =\u003e n.type === 'pe');  // RG mode: PE included in mainNodes\nconst mainNodes = useRgLayout ? NODES : NODES.filter(n =\u003e n.type !== 'pe');\n\n// Group box layout parameters\nconst GROUP_PAD = 24;\nconst GROUP_TITLE_H = 28;\nconst GROUP_GAP = 60;\nconst COLS_PER_GROUP = 3;\nconst CELL_W = SVC_W + 100;\nconst CELL_H = SVC_H + 90;\n\nfunction groupDimensions(nodeCount) {{\n  const cols = Math.min(nodeCount, COLS_PER_GROUP);\n  const rows = Math.ceil(nodeCount / COLS_PER_GROUP);\n  const w = cols * CELL_W + GROUP_PAD * 2;\n  const h = rows * CELL_H + GROUP_PAD + GROUP_TITLE_H;\n  return {{ w, h, cols, rows }};\n}}\n\nconst groupBoxes = [];\n\n// ── Layout strategy: RG-based (if HIERARCHY) or Category-based (default) ──\n\nif (useRgLayout) {{\n  // ── RG-based layout: group by Subscription \u003e ResourceGroup ──\n  let gx = 60, gy = 140;\n  let subStartX = 60;\n  const SUB_GAP = 80;\n  const RG_GAP = 60;\n\n  HIERARCHY.forEach((sub, subIdx) =\u003e {{\n    let rgX = gx;\n    let rgMaxH = 0;\n\n    const subRGs = sub.resourceGroups || [];\n    subRGs.forEach((rgName, rgIdx) =\u003e {{\n      const rgNodes = mainNodes.filter(n =\u003e n.subscription === sub.subscription \u0026\u0026 n.resourceGroup === rgName);\n      if (rgNodes.length === 0) return;\n\n      const dim = groupDimensions(rgNodes.length);\n\n      rgNodes.forEach((n, i) =\u003e {{\n        const col = i % dim.cols;\n        const row = Math.floor(i / dim.cols);\n        positions[n.id] = {{\n          x: rgX + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,\n          y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2\n        }};\n      }});\n\n      groupBoxes.push({{\n        cat: rgName, x: rgX, y: gy, w: dim.w, h: dim.h,\n        color: rgNodes[0]?.color || '#0078D4',\n        isRG: true, subscription: sub.subscription\n      }});\n\n      rgX += dim.w + RG_GAP;\n      rgMaxH = Math.max(rgMaxH, dim.h);\n    }});\n\n    // Next subscription row\n    if (subIdx \u003c HIERARCHY.length - 1) {{\n      gy += rgMaxH + SUB_GAP;\n      gx = subStartX;\n    }}\n  }});\n\n  // Place unassigned main nodes (no subscription/RG) in a generic group\n  const unassigned = mainNodes.filter(n =\u003e !positions[n.id]);\n  if (unassigned.length \u003e 0) {{\n    const allY = Object.values(positions).map(p =\u003e p.y);\n    const bottomY = allY.length \u003e 0 ? Math.max(...allY) + SVC_H + GROUP_GAP : 140;\n    const dim = groupDimensions(unassigned.length);\n    unassigned.forEach((n, i) =\u003e {{\n      const col = i % dim.cols;\n      const row = Math.floor(i / dim.cols);\n      positions[n.id] = {{\n        x: 60 + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,\n        y: bottomY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2\n      }};\n    }});\n    groupBoxes.push({{\n      cat: 'Other', x: 60, y: bottomY, w: dim.w, h: dim.h,\n      color: '#666'\n    }});\n  }}\n\n}} else {{\n  // ── Category-based layout (original) ──\n  const bottomCategories = ['Network', 'External', 'Monitor', 'Monitoring'];\n  const catOrder = ['AI', 'Data', 'Security', 'Compute', 'Integration', 'DevOps', 'IoT', 'Azure'];\n\n  const catGroups = {{}};\n  mainNodes.forEach(n =\u003e {{\n    const cat = n.category || 'Azure';\n    if (!catGroups[cat]) catGroups[cat] = [];\n    catGroups[cat].push(n);\n  }});\n\n// Dynamically include any categories not in catOrder or bottomCategories\nconst extraCats = Object.keys(catGroups).filter(cat =\u003e !catOrder.includes(cat) \u0026\u0026 !bottomCategories.includes(cat));\nconst fullCatOrder = [...catOrder, ...extraCats];\n\n// ── Place main service groups in a flowing grid ──\nconst serviceGroups = fullCatOrder.filter(cat =\u003e catGroups[cat] \u0026\u0026 catGroups[cat].length \u003e 0\n  \u0026\u0026 !bottomCategories.includes(cat));\n\nlet gx = 60, gy = 140;\nlet rowMaxH = 0;\nlet rowStartX = 60;\nconst MAX_ROW_W = Math.max(1600, serviceGroups.length * 400);\n\nserviceGroups.forEach(cat =\u003e {{\n  const nodes = catGroups[cat];\n  const dim = groupDimensions(nodes.length);\n\n  // Wrap to next row if too wide\n  if (gx + dim.w \u003e rowStartX + MAX_ROW_W \u0026\u0026 gx \u003e rowStartX) {{\n    gx = rowStartX;\n    gy += rowMaxH + GROUP_GAP;\n    rowMaxH = 0;\n  }}\n\n  // Place nodes inside group grid\n  nodes.forEach((n, i) =\u003e {{\n    const col = i % dim.cols;\n    const row = Math.floor(i / dim.cols);\n    positions[n.id] = {{\n      x: gx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,\n      y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2\n    }};\n  }});\n\n  groupBoxes.push({{\n    cat, x: gx, y: gy, w: dim.w, h: dim.h,\n    color: nodes[0]?.color || '#0078D4'\n  }});\n\n  gx += dim.w + GROUP_GAP;\n  rowMaxH = Math.max(rowMaxH, dim.h);\n}});\n\n// ── Place bottom groups (Network, External, Monitor) ──\nconst bottomGroupY = gy + rowMaxH + GROUP_GAP + 20;\nlet bgx = 60;\nbottomCategories.forEach(cat =\u003e {{\n  const nodes = catGroups[cat];\n  if (!nodes || nodes.length === 0) return;\n  const dim = groupDimensions(nodes.length);\n\n  nodes.forEach((n, i) =\u003e {{\n    const col = i % dim.cols;\n    const row = Math.floor(i / dim.cols);\n    positions[n.id] = {{\n      x: bgx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,\n      y: bottomGroupY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2\n    }};\n  }});\n\n  groupBoxes.push({{\n    cat, x: bgx, y: bottomGroupY, w: dim.w, h: dim.h,\n    color: nodes[0]?.color || '#666',\n    isBottom: true\n  }});\n\n  bgx += dim.w + GROUP_GAP;\n}});\n\n}} // end of else (category-based layout)\n\n// ── PE nodes placement ──\nif (useRgLayout) {{\n  // RG mode: PE nodes go inside their respective RG boxes\n  // PE positions are already set by the RG layout if they have subscription/resourceGroup\n  // For PEs without RG assignment, place them in a separate group\n  const unplacedPEs = peNodes.filter(pe =\u003e !positions[pe.id]);\n  if (unplacedPEs.length \u003e 0) {{\n    // Find the rightmost RG box position\n    const allGbRight = groupBoxes.length \u003e 0 ? Math.max(...groupBoxes.map(gb =\u003e gb.x + gb.w)) : 0;\n    const peStartX = allGbRight + GROUP_GAP;\n    const peStartY = 140;\n    const peCols = Math.min(unplacedPEs.length, 4);\n    const peCellW = PE_W + 50;\n    const peCellH = PE_H + 30;\n    const peBoxW = peCols * peCellW + GROUP_PAD * 2;\n    const peRows = Math.ceil(unplacedPEs.length / peCols);\n    const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;\n    \n    unplacedPEs.forEach((pe, i) =\u003e {{\n      const col = i % peCols;\n      const row = Math.floor(i / peCols);\n      positions[pe.id] = {{\n        x: peStartX + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,\n        y: peStartY + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2\n      }};\n    }});\n    groupBoxes.push({{\n      cat: 'Private Endpoints', x: peStartX, y: peStartY, w: peBoxW, h: peBoxH,\n      color: '#5C2D91', isPE: true\n    }});\n  }}\n}} else {{\n  // Category mode: PE nodes in separate group above service groups\n  const PE_Y = 40;\n  if (peNodes.length \u003e 0) {{\n    const peCols = Math.min(peNodes.length, 6);\n    const peRows = Math.ceil(peNodes.length / peCols);\n    const peCellW = PE_W + 50;\n    const peCellH = PE_H + 30;\n    const peBoxW = peCols * peCellW + GROUP_PAD * 2;\n    const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;\n\n    peNodes.forEach((pe, i) =\u003e {{\n      const col = i % peCols;\n      const row = Math.floor(i / peCols);\n      positions[pe.id] = {{\n        x: 60 + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,\n        y: PE_Y + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2\n      }};\n    }});\n\n    groupBoxes.push({{\n      cat: 'Private Endpoints', x: 60, y: PE_Y, w: peBoxW, h: peBoxH,\n      color: '#5C2D91', isPE: true\n    }});\n\n    const peBottom = PE_Y + peBoxH + GROUP_GAP;\n    if (peBottom \u003e 140) {{\n      const shift = peBottom - 140;\n      NODES.forEach(n =\u003e {{\n        if (n.type !== 'pe' \u0026\u0026 positions[n.id]) {{\n          positions[n.id].y += shift;\n        }}\n      }});\n      groupBoxes.forEach(gb =\u003e {{\n        if (!gb.isPE) gb.y += shift;\n      }});\n    }}\n  }}\n}}\n\n// ── Node → Group mapping (for edge routing) ──\nconst nodeGroupMap = {{}};\ngroupBoxes.forEach((gb, idx) =\u003e {{\n  NODES.forEach(n =\u003e {{\n    const pos = positions[n.id];\n    if (!pos) return;\n    const nw = n.type === 'pe' ? PE_W : SVC_W;\n    const nh = n.type === 'pe' ? PE_H : SVC_H;\n    const ncx = pos.x + nw / 2;\n    const ncy = pos.y + nh / 2;\n    if (ncx \u003e= gb.x \u0026\u0026 ncx \u003c= gb.x + gb.w \u0026\u0026 ncy \u003e= gb.y \u0026\u0026 ncy \u003c= gb.y + gb.h) {{\n      nodeGroupMap[n.id] = idx;\n    }}\n  }});\n}});\n// Routing corridor margins (outside all group boxes)\nconst _rightMarginBase = groupBoxes.length \u003e 0 ? Math.max(...groupBoxes.map(g =\u003e g.x + g.w)) + 40 : 800;\nconst _leftMarginBase = groupBoxes.length \u003e 0 ? Math.min(...groupBoxes.map(g =\u003e g.x)) - 40 : -40;\n\n// ── State ──\nlet dragging = null, dragOffX = 0, dragOffY = 0;\nlet draggingGroup = null, groupDragNodes = [];  // for RG/group box dragging\nlet _dragStartX = 0, _dragStartY = 0, _didDrag = false;  // global so renderDiagram rebuilding DOM mid-drag doesn't reset them\nlet viewTransform = {{ x: 0, y: 0, scale: 1 }};\nlet isPanning = false, panSX = 0, panSY = 0, panSTx = 0, panSTy = 0;\nlet _routeCounter = 0;\n\n// ── Bidirectional highlight ──\nlet _selectedNodeId = null;\n\nfunction selectNode(nodeId) {{\n  const wasSelected = _selectedNodeId === nodeId;\n\n  // Clear all selections\n  clearSelection();\n\n  // Toggle off if clicking same node\n  if (wasSelected) {{ _selectedNodeId = null; return; }}\n\n  _selectedNodeId = nodeId;\n  applySelectionHighlight();\n  // Scroll sidebar card into view on initial selection\n  const sCard = document.getElementById('card-' + nodeId);\n  if (sCard) sCard.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});\n}}\n\n// Re-apply CSS classes for current _selectedNodeId (called after renderDiagram rebuilds DOM)\nfunction applySelectionHighlight() {{\n  const nodeId = _selectedNodeId;\n  if (!nodeId) return;\n\n  // Highlight diagram node\n  const svgNode = document.querySelector(`.node[data-id=\"${{nodeId}}\"]`);\n  if (svgNode) svgNode.classList.add('selected');\n  // Highlight sidebar card\n  const card = document.getElementById('card-' + nodeId);\n  if (card) card.classList.add('selected');\n\n  // Find connected edges (where this node is from or to)\n  const connectedNodeIds = new Set([nodeId]);\n  document.querySelectorAll('.edge-path').forEach(p =\u003e {{\n    const f = p.getAttribute('data-from'), t = p.getAttribute('data-to');\n    if (f === nodeId || t === nodeId) {{\n      p.classList.add('highlight');\n      connectedNodeIds.add(f);\n      connectedNodeIds.add(t);\n    }} else {{\n      p.classList.add('dimmed');\n    }}\n  }});\n  document.querySelectorAll('.edge-label').forEach(g =\u003e {{\n    const f = g.getAttribute('data-from'), t = g.getAttribute('data-to');\n    if (f === nodeId || t === nodeId) {{\n      g.classList.add('highlight');\n      g.querySelector('.edge-label-bg')?.classList.add('highlight');\n    }} else {{\n      g.classList.add('dimmed');\n      g.querySelector('.edge-label-bg')?.classList.add('dimmed');\n    }}\n  }});\n  // Dim unconnected nodes\n  document.querySelectorAll('.node').forEach(n =\u003e {{\n    const nid = n.getAttribute('data-id');\n    if (!connectedNodeIds.has(nid)) n.classList.add('dimmed');\n  }});\n}}\n\nfunction clearSelection() {{\n  _selectedNodeId = null;\n  document.querySelectorAll('.node').forEach(n =\u003e {{ n.classList.remove('selected', 'dimmed'); }});\n  document.querySelectorAll('.service-card').forEach(c =\u003e c.classList.remove('selected'));\n  document.querySelectorAll('.edge-path').forEach(p =\u003e {{ p.classList.remove('highlight', 'dimmed'); }});\n  document.querySelectorAll('.edge-label').forEach(g =\u003e {{ g.classList.remove('highlight', 'dimmed'); }});\n  document.querySelectorAll('.edge-label-bg').forEach(r =\u003e {{ r.classList.remove('highlight', 'dimmed'); }});\n}}\n\nfunction markerFor(type) {{\n  if (type === 'data') return 'arr-data';\n  if (type === 'security') return 'arr-sec';\n  if (type === 'private') return 'arr-pe';\n  return 'arr';\n}}\n\nfunction renderDiagram() {{\n  const root = document.getElementById('diagram-root');\n  root.innerHTML = '';\n  _routeCounter = 0;  // reset stagger counter each render\n\n  // ── VNet bounds (hoisted so avoidNodes can push detours outside VNet) ──\n  let _vnetBounds = null;\n  if (!useRgLayout) {{\n    const _pg = groupBoxes.filter(gb =\u003e !gb.isBottom);\n    const _hasPriv = NODES.some(n =\u003e n.private \u0026\u0026 n.type !== 'pe');\n    const _hasVNI = VNET_INFO \u0026\u0026 VNET_INFO.length \u003e 0;\n    const _hasPe = NODES.some(n =\u003e n.type === 'pe');\n    if (_pg.length \u003e 0 \u0026\u0026 (_hasPriv || _hasVNI || _hasPe)) {{\n      const vx = Math.min(..._pg.map(g =\u003e g.x)) - 16;\n      const vy = Math.min(..._pg.map(g =\u003e g.y)) - 36;\n      const vR = Math.max(..._pg.map(g =\u003e g.x + g.w)) + 16;\n      const vB = Math.max(..._pg.map(g =\u003e g.y + g.h)) + 16;\n      _vnetBounds = {{ x: vx, y: vy, w: vR - vx, h: vB - vy }};\n    }}\n  }}\n\n  // ── Draw VNet boundary (only in category-based layout, not RG layout) ──\n  if (!useRgLayout) {{\n  const privateGroups = groupBoxes.filter(gb =\u003e !gb.isBottom);\n  const hasPrivateNodes = NODES.some(n =\u003e n.private \u0026\u0026 n.type !== 'pe');\n  const hasVNetInfo = VNET_INFO \u0026\u0026 VNET_INFO.length \u003e 0;\n  const hasPeNodes = NODES.some(n =\u003e n.type === 'pe');\n  const showVNet = hasPrivateNodes || hasVNetInfo || hasPeNodes;\n\n  if (privateGroups.length \u003e 0 \u0026\u0026 showVNet) {{\n      const vx = Math.min(...privateGroups.map(g =\u003e g.x)) - 16;\n      const vy = Math.min(...privateGroups.map(g =\u003e g.y)) - 36;\n      const vRight = Math.max(...privateGroups.map(g =\u003e g.x + g.w)) + 16;\n      const vBottom = Math.max(...privateGroups.map(g =\u003e g.y + g.h)) + 16;\n\n      const vr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n      vr.setAttribute('x', vx); vr.setAttribute('y', vy);\n      vr.setAttribute('width', vRight - vx); vr.setAttribute('height', vBottom - vy);\n      vr.setAttribute('fill', '#f8f7ff'); vr.setAttribute('stroke', '#5C2D91');\n      vr.setAttribute('stroke-width', '2'); vr.setAttribute('stroke-dasharray', '8,4');\n      vr.setAttribute('rx', '12');\n      root.appendChild(vr);\n\n      const vnetLabel = VNET_INFO ? `Virtual Network (${{VNET_INFO}})` : 'Virtual Network';\n      const vl = document.createElementNS('http://www.w3.org/2000/svg', 'g');\n      vl.setAttribute('class', 'vnet-boundary-label');\n      vl.setAttribute('style', 'cursor: pointer;');\n      vl.innerHTML = `\u003csvg x=\"${{vx + 10}}\" y=\"${{vy + 6}}\" width=\"20\" height=\"20\" viewBox=\"0 0 48 48\"\u003e\n        \u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"none\" stroke=\"#5C2D91\" stroke-width=\"3\"/\u003e\n        \u003ccircle cx=\"16\" cy=\"18\" r=\"3\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"32\" cy=\"18\" r=\"3\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"24\" cy=\"32\" r=\"3\" fill=\"#5C2D91\"/\u003e\n      \u003c/svg\u003e\n      \u003ctext x=\"${{vx + 34}}\" y=\"${{vy + 20}}\" font-size=\"12\" font-weight=\"600\" fill=\"#5C2D91\" font-family=\"Segoe UI, sans-serif\"\u003e${{vnetLabel}}\u003c/text\u003e`;\n      root.appendChild(vl);\n\n      // Store VNet rect reference for highlight\n      vr.setAttribute('id', 'vnet-rect');\n      vl.addEventListener('click', () =\u003e {{ toggleVNetHighlight(); }});\n      root.appendChild(vl);\n  }}\n  }} // end if(!useRgLayout) for VNet boundary\n\n  // ── Draw group boxes (category or RG — depends on layout mode) ──\n  const _groupLabelElements = []; // store labels to re-render on top of edges\n  groupBoxes.forEach(gb =\u003e {{\n    if (gb.isPE) {{\n      // PE group — always draw with dashed style\n      const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n      gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);\n      gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);\n      gr.setAttribute('rx', '8'); gr.setAttribute('fill', '#f3eef9');\n      gr.setAttribute('stroke', '#c8b8e8'); gr.setAttribute('stroke-width', '1.2');\n      gr.setAttribute('stroke-dasharray', '4,4');\n      root.appendChild(gr);\n    }} else {{\n      // Service group (category or RG)\n      const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n      gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);\n      gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);\n      gr.setAttribute('rx', '8');\n      gr.setAttribute('fill', gb.isRG ? '#fafafa' : 'white');\n      gr.setAttribute('stroke', gb.isRG ? gb.color : '#c8c6c4');\n      gr.setAttribute('stroke-width', gb.isRG ? '1.5' : '1.2');\n      if (gb.isRG) gr.setAttribute('stroke-dasharray', '6,3');\n      root.appendChild(gr);\n    }}\n\n    // Title bar\n    const titleBar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    titleBar.setAttribute('x', gb.x); titleBar.setAttribute('y', gb.y);\n    titleBar.setAttribute('width', gb.w); titleBar.setAttribute('height', GROUP_TITLE_H);\n    titleBar.setAttribute('rx', '8');\n    titleBar.setAttribute('fill', gb.color);\n    titleBar.setAttribute('opacity', '0.1');\n    root.appendChild(titleBar);\n    const titleFill = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    titleFill.setAttribute('x', gb.x); titleFill.setAttribute('y', gb.y + GROUP_TITLE_H - 8);\n    titleFill.setAttribute('width', gb.w); titleFill.setAttribute('height', '8');\n    titleFill.setAttribute('fill', gb.color); titleFill.setAttribute('opacity', '0.1');\n    root.appendChild(titleFill);\n\n    // Color accent line\n    const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    accent.setAttribute('x', gb.x); accent.setAttribute('y', gb.y);\n    accent.setAttribute('width', gb.w); accent.setAttribute('height', '3');\n    accent.setAttribute('rx', '8'); accent.setAttribute('fill', gb.color);\n    root.appendChild(accent);\n\n    // Group label — RG uses 📁, PE uses \"Private Endpoints\", category uses category name\n    const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n    label.setAttribute('x', gb.x + 12); label.setAttribute('y', gb.y + 18);\n    label.setAttribute('font-size', '12'); label.setAttribute('font-weight', '600');\n    label.setAttribute('fill', gb.color); label.setAttribute('font-family', 'Segoe UI, sans-serif');\n    label.textContent = gb.isRG ? `📁 ${{gb.cat}}` : gb.cat;\n    root.appendChild(label);\n    _groupLabelElements.push(label);\n\n    // Make title bar draggable — drags all nodes inside\n    titleBar.style.cursor = 'grab';\n    const gbIdx = groupBoxes.indexOf(gb);\n    titleBar.addEventListener('mousedown', e =\u003e {{\n      if (e.button !== 0) return;\n      e.stopPropagation(); e.preventDefault();\n      draggingGroup = gbIdx;\n      const svgPt = getSVGPoint(e);\n      dragOffX = svgPt.x; dragOffY = svgPt.y;\n      // Find all nodes inside this group box\n      groupDragNodes = NODES.filter(n =\u003e {{\n        const pos = positions[n.id];\n        if (!pos) return false;\n        const nw = n.type === 'pe' ? PE_W : SVC_W;\n        const nh = n.type === 'pe' ? PE_H : SVC_H;\n        const cx = pos.x + nw/2, cy = pos.y + nh/2;\n        return cx \u003e= gb.x \u0026\u0026 cx \u003c= gb.x + gb.w \u0026\u0026 cy \u003e= gb.y \u0026\u0026 cy \u003c= gb.y + gb.h;\n      }}).map(n =\u003e n.id);\n    }});\n  }});\n\n  // ── Draw Subscription boundaries (only if multiple subscriptions, rendered AFTER group boxes) ──\n  if (HIERARCHY.length \u003e 1 \u0026\u0026 useRgLayout) {{\n    HIERARCHY.forEach((sub, subIdx) =\u003e {{\n      // Find all RG boxes belonging to this subscription\n      const subRgBoxes = groupBoxes.filter(gb =\u003e gb.isRG \u0026\u0026 gb.subscription === sub.subscription);\n      if (subRgBoxes.length === 0) return;\n      \n      const sx = Math.min(...subRgBoxes.map(gb =\u003e gb.x)) - 20;\n      const sy = Math.min(...subRgBoxes.map(gb =\u003e gb.y)) - 40;\n      const sRight = Math.max(...subRgBoxes.map(gb =\u003e gb.x + gb.w)) + 20;\n      const sBottom = Math.max(...subRgBoxes.map(gb =\u003e gb.y + gb.h)) + 20;\n      \n      const sr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n      sr.setAttribute('x', sx); sr.setAttribute('y', sy);\n      sr.setAttribute('width', sRight - sx); sr.setAttribute('height', sBottom - sy);\n      sr.setAttribute('fill', 'none'); sr.setAttribute('stroke', '#0078D4');\n      sr.setAttribute('stroke-width', '2.5'); sr.setAttribute('stroke-dasharray', '12,4');\n      sr.setAttribute('rx', '16'); sr.setAttribute('opacity', '0.7');\n      root.appendChild(sr);\n      \n      const sl = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n      sl.setAttribute('x', sx + 12); sl.setAttribute('y', sy + 16);\n      sl.setAttribute('font-size', '12'); sl.setAttribute('font-weight', '700');\n      sl.setAttribute('fill', '#0078D4'); sl.setAttribute('font-family', 'Segoe UI, sans-serif');\n      sl.textContent = `📦 ${{sub.subscription}}`;\n      root.appendChild(sl);\n    }});\n  }}\n\n  // ── Edge routing (obstacle-free) ──\n  // Compute global bounds: the absolute bottom of ALL nodes\n  function getGlobalBounds() {{\n    let minY = Infinity, maxY = -Infinity;\n    NODES.forEach(n =\u003e {{\n      const pos = positions[n.id];\n      if (!pos) return;\n      const h = n.type === 'pe' ? PE_H : SVC_H;\n      if (pos.y \u003c minY) minY = pos.y;\n      if (pos.y + h \u003e maxY) maxY = pos.y + h;\n    }});\n    return {{ minY, maxY }};\n  }}\n\n  function getNodeBox(node) {{\n    const pos = positions[node.id];\n    if (!pos) return null;\n    const w = node.type === 'pe' ? PE_W : SVC_W;\n    const h = node.type === 'pe' ? PE_H : SVC_H;\n    return {{ x: pos.x, y: pos.y, w, h, cx: pos.x + w/2, cy: pos.y + h/2 }};\n  }}\n\n  // Border point: exit/enter at edge of rectangle\n  function borderExit(box, side) {{\n    // side: 'top', 'bottom', 'left', 'right'\n    if (side === 'top') return {{ x: box.cx, y: box.y }};\n    if (side === 'bottom') return {{ x: box.cx, y: box.y + box.h }};\n    if (side === 'left') return {{ x: box.x, y: box.cy }};\n    if (side === 'right') return {{ x: box.x + box.w, y: box.cy }};\n  }}\n\n  // Check if a line segment hits any group box (for edge routing)\n  function hitsGroupBox(x1, y1, x2, y2, skipGroupIndices) {{\n    for (let gi = 0; gi \u003c groupBoxes.length; gi++) {{\n      if (skipGroupIndices.includes(gi)) continue;\n      const gb = groupBoxes[gi];\n      const pad = 4;\n      const left = gb.x - pad, right = gb.x + gb.w + pad;\n      const top = gb.y - pad, bottom = gb.y + gb.h + pad;\n      const dx = x2 - x1, dy = y2 - y1;\n      let tmin = 0, tmax = 1;\n      const edges = [[-dx, x1 - left], [dx, right - x1], [-dy, y1 - top], [dy, bottom - y1]];\n      let hit = true;\n      for (const [p, q] of edges) {{\n        if (Math.abs(p) \u003c 0.001) {{ if (q \u003c 0) {{ hit = false; break; }} }}\n        else {{\n          const t = q / p;\n          if (p \u003c 0) {{ if (t \u003e tmin) tmin = t; }}\n          else {{ if (t \u003c tmax) tmax = t; }}\n          if (tmin \u003e tmax) {{ hit = false; break; }}\n        }}\n      }}\n      if (hit \u0026\u0026 tmin \u003c tmax) return true;\n    }}\n    return false;\n  }}\n\n  // Find gap between adjacent groups (same row)\n  function findGapBetween(gi1, gi2) {{\n    if (gi1 \u003c 0 || gi2 \u003c 0) return null;\n    const g1 = groupBoxes[gi1], g2 = groupBoxes[gi2];\n    // Same row: Y ranges overlap\n    const yOverlap = g1.y \u003c g2.y + g2.h \u0026\u0026 g2.y \u003c g1.y + g1.h;\n    if (!yOverlap) return null;\n    // Gap between them\n    if (g1.x + g1.w \u003c g2.x) return {{ x: (g1.x + g1.w + g2.x) / 2 }};\n    if (g2.x + g2.w \u003c g1.x) return {{ x: (g2.x + g2.w + g1.x) / 2 }};\n    return null;\n  }}\n\n  // Build orthogonal path with rounded corners\n  function buildOrthoPath(pts) {{\n    let d = `M ${{pts[0].x}} ${{pts[0].y}}`;\n    const radius = 6;\n    for (let i = 1; i \u003c pts.length - 1; i++) {{\n      const prev = pts[i-1], curr = pts[i], next = pts[i+1];\n      const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;\n      const dx2 = next.x - curr.x, dy2 = next.y - curr.y;\n      const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);\n      const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);\n      if (len1 \u003c 1 || len2 \u003c 1) {{ d += ` L ${{curr.x}} ${{curr.y}}`; continue; }}\n      const r = Math.min(radius, len1/2, len2/2);\n      const bx = curr.x - (dx1/len1)*r, by = curr.y - (dy1/len1)*r;\n      const ax = curr.x + (dx2/len2)*r, ay = curr.y + (dy2/len2)*r;\n      d += ` L ${{bx}} ${{by}} Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;\n    }}\n    d += ` L ${{pts[pts.length-1].x}} ${{pts[pts.length-1].y}}`;\n    return d;\n  }}\n\n  // Find crossing point between two orthogonal segments (H crosses V only)\n  function findSegCrossing(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {{\n    const aIsH = Math.abs(ay1 - ay2) \u003c 1;\n    const bIsH = Math.abs(by1 - by2) \u003c 1;\n    if (aIsH === bIsH) return null;\n    let hx1, hx2, hy, vx, vy1, vy2;\n    if (aIsH) {{\n      hy = ay1; hx1 = Math.min(ax1, ax2); hx2 = Math.max(ax1, ax2);\n      vx = bx1; vy1 = Math.min(by1, by2); vy2 = Math.max(by1, by2);\n    }} else {{\n      hy = by1; hx1 = Math.min(bx1, bx2); hx2 = Math.max(bx1, bx2);\n      vx = ax1; vy1 = Math.min(ay1, ay2); vy2 = Math.max(ay1, ay2);\n    }}\n    const MM = 2;\n    if (vx \u003e hx1 + MM \u0026\u0026 vx \u003c hx2 - MM \u0026\u0026\n        hy \u003e vy1 + MM \u0026\u0026 hy \u003c vy2 - MM) {{\n      return {{ x: vx, y: hy }};\n    }}\n    return null;\n  }}\n\n  // Build orthogonal path with rounded corners AND bridge arcs at crossing points\n  function buildPathWithBridges(pts, bridges) {{\n    const CR = 6, BR = 12;\n    if (pts.length \u003c= 1) return '';\n\n    // Index bridges by segment, sort along travel direction\n    const bySeg = {{}};\n    (bridges || []).forEach(b =\u003e {{\n      if (!bySeg[b.segIdx]) bySeg[b.segIdx] = [];\n      bySeg[b.segIdx].push(b);\n    }});\n    for (const si in bySeg) {{\n      const i = parseInt(si);\n      if (i \u003e= pts.length - 1) continue;\n      const p1 = pts[i], p2 = pts[i + 1];\n      const isH = Math.abs(p1.y - p2.y) \u003c 1;\n      if (isH) {{\n        const dir = Math.sign(p2.x - p1.x) || 1;\n        bySeg[si].sort((a, b) =\u003e (a.x - b.x) * dir);\n      }} else {{\n        const dir = Math.sign(p2.y - p1.y) || 1;\n        bySeg[si].sort((a, b) =\u003e (a.y - b.y) * dir);\n      }}\n    }}\n\n    // Helper: append bridge arcs for a segment\n    function appendBridges(d, segIdx, segP1, segP2) {{\n      const segB = bySeg[segIdx] || [];\n      if (segB.length === 0) return d;\n      const isH = Math.abs(segP1.y - segP2.y) \u003c 1;\n      segB.forEach(b =\u003e {{\n        if (isH) {{\n          const dir = Math.sign(segP2.x - segP1.x) || 1;\n          d += ` L ${{b.x - BR * dir}} ${{segP1.y}}`;\n          d += ` A ${{BR}} ${{BR}} 0 0 ${{dir \u003e 0 ? 1 : 0}} ${{b.x + BR * dir}} ${{segP1.y}}`;\n        }} else {{\n          const dir = Math.sign(segP2.y - segP1.y) || 1;\n          d += ` L ${{segP1.x}} ${{b.y - BR * dir}}`;\n          d += ` A ${{BR}} ${{BR}} 0 0 ${{dir \u003e 0 ? 0 : 1}} ${{segP1.x}} ${{b.y + BR * dir}}`;\n        }}\n      }});\n      return d;\n    }}\n\n    // 2-point path (straight line)\n    if (pts.length === 2) {{\n      let d = `M ${{pts[0].x}} ${{pts[0].y}}`;\n      d = appendBridges(d, 0, pts[0], pts[1]);\n      d += ` L ${{pts[1].x}} ${{pts[1].y}}`;\n      return d;\n    }}\n\n    // Multi-point path with corners + bridges\n    let d = `M ${{pts[0].x}} ${{pts[0].y}}`;\n    for (let i = 1; i \u003c pts.length; i++) {{\n      const prev = pts[i - 1], curr = pts[i];\n      const isLast = (i === pts.length - 1);\n\n      // Compute corner trimming for non-last points\n      let target = curr, cSuffix = '';\n      if (!isLast) {{\n        const next = pts[i + 1];\n        const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;\n        const dx2 = next.x - curr.x, dy2 = next.y - curr.y;\n        const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);\n        const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);\n        if (len1 \u003e= 1 \u0026\u0026 len2 \u003e= 1) {{\n          const r = Math.min(CR, len1 / 2, len2 / 2);\n          const bx = curr.x - (dx1 / len1) * r;\n          const by = curr.y - (dy1 / len1) * r;\n          const ax = curr.x + (dx2 / len2) * r;\n          const ay = curr.y + (dy2 / len2) * r;\n          target = {{ x: bx, y: by }};\n          cSuffix = ` Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;\n        }}\n      }}\n\n      // Draw bridges on segment (i-1) → i\n      d = appendBridges(d, i - 1, prev, curr);\n\n      // Line to target + optional corner curve\n      d += ` L ${{target.x}} ${{target.y}}${{cSuffix}}`;\n    }}\n    return d;\n  }}\n\n  // ── Obstacle avoidance: route edges around nodes ──\n  function segHitsNode(x1, y1, x2, y2, pos, nw, nh, margin) {{\n    const nx1 = pos.x - margin, ny1 = pos.y - margin;\n    const nx2 = pos.x + nw + margin, ny2 = pos.y + nh + margin;\n    if (Math.abs(x1 - x2) \u003c 1) {{\n      // Vertical segment\n      const x = x1;\n      const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);\n      return x \u003e nx1 \u0026\u0026 x \u003c nx2 \u0026\u0026 maxY \u003e ny1 \u0026\u0026 minY \u003c ny2;\n    }} else {{\n      // Horizontal segment\n      const y = y1;\n      const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);\n      return y \u003e ny1 \u0026\u0026 y \u003c ny2 \u0026\u0026 maxX \u003e nx1 \u0026\u0026 minX \u003c nx2;\n    }}\n  }}\n\n  function avoidNodes(pts, fromId, toId) {{\n    const MARGIN = 25;\n    const SECTION_MARGIN = 12;\n    let points = pts.map(p =\u003e ({{...p}}));\n    // Save original anchors — these must NEVER move (they attach to nodes)\n    const startAnchor = {{...points[0]}};\n    const endAnchor = {{...points[points.length - 1]}};\n\n    // Section (groupBox) obstacles: groupBoxes containing NEITHER endpoint.\n    // Skip PE group since PE-type edges legitimately traverse into it.\n    const _fromGrp = _nodeGrp[fromId];\n    const _toGrp = _nodeGrp[toId];\n    const sectionObstacles = [];\n    for (let gi = 0; gi \u003c groupBoxes.length; gi++) {{\n      if (gi === _fromGrp || gi === _toGrp) continue;\n      const gb = groupBoxes[gi];\n      if (gb.isPE) continue;\n      sectionObstacles.push(gb);\n    }}\n\n    // Helper: if the section detour coord lands inside the VNet rect while\n    // either endpoint sits outside the VNet, push the detour past the nearer\n    // VNet edge so unrelated VNet interior is not traversed.\n    function _clampOutsideVNet(val, axis) {{\n      if (!_vnetBounds) return val;\n      const inAnchor = (a) =\u003e (a.x \u003e _vnetBounds.x \u0026\u0026 a.x \u003c _vnetBounds.x + _vnetBounds.w\n                            \u0026\u0026 a.y \u003e _vnetBounds.y \u0026\u0026 a.y \u003c _vnetBounds.y + _vnetBounds.h);\n      const srcOut = !inAnchor(startAnchor);\n      const dstOut = !inAnchor(endAnchor);\n      if (!srcOut \u0026\u0026 !dstOut) return val;\n      if (axis === 'x') {{\n        const L = _vnetBounds.x, R = _vnetBounds.x + _vnetBounds.w;\n        if (val \u003e L \u0026\u0026 val \u003c R) return (val - L) \u003c= (R - val) ? L - SECTION_MARGIN : R + SECTION_MARGIN;\n      }} else {{\n        const T = _vnetBounds.y, B = _vnetBounds.y + _vnetBounds.h;\n        if (val \u003e T \u0026\u0026 val \u003c B) return (val - T) \u003c= (B - val) ? T - SECTION_MARGIN : B + SECTION_MARGIN;\n      }}\n      return val;\n    }}\n\n    for (let iter = 0; iter \u003c 20; iter++) {{\n      let found = false;\n\n      for (let i = 0; i \u003c points.length - 1 \u0026\u0026 !found; i++) {{\n        const p1 = points[i], p2 = points[i+1];\n\n        // 1) Section obstacles (larger, checked first)\n        for (const gb of sectionObstacles) {{\n          const pos = {{x: gb.x, y: gb.y}};\n          if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, gb.w, gb.h, SECTION_MARGIN)) continue;\n\n          found = true;\n          const isVert = Math.abs(p1.x - p2.x) \u003c 1;\n          const isFirst = (i === 0);\n          const isLast = (i + 1 === points.length - 1);\n\n          if (points.length \u003c= 2) {{\n            if (isVert) {{\n              const leftX = gb.x - SECTION_MARGIN;\n              const rightX = gb.x + gb.w + SECTION_MARGIN;\n              let detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              detourX = _clampOutsideVNet(detourX, 'x');\n              points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];\n            }} else {{\n              const topY = gb.y - SECTION_MARGIN;\n              const bottomY = gb.y + gb.h + SECTION_MARGIN;\n              let detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              detourY = _clampOutsideVNet(detourY, 'y');\n              points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];\n            }}\n          }} else if (isFirst) {{\n            if (isVert) {{\n              const leftX = gb.x - SECTION_MARGIN;\n              const rightX = gb.x + gb.w + SECTION_MARGIN;\n              let detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              detourX = _clampOutsideVNet(detourX, 'x');\n              points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});\n              points[3] = {{x: detourX, y: p2.y}};\n            }} else {{\n              const topY = gb.y - SECTION_MARGIN;\n              const bottomY = gb.y + gb.h + SECTION_MARGIN;\n              let detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              detourY = _clampOutsideVNet(detourY, 'y');\n              points.splice(1, 0, {{x: p1.x, y: detourY}});\n              points[2] = {{x: p2.x, y: detourY}};\n            }}\n          }} else if (isLast) {{\n            if (isVert) {{\n              const leftX = gb.x - SECTION_MARGIN;\n              const rightX = gb.x + gb.w + SECTION_MARGIN;\n              let detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              detourX = _clampOutsideVNet(detourX, 'x');\n              points[i] = {{x: detourX, y: p1.y}};\n              points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});\n            }} else {{\n              const topY = gb.y - SECTION_MARGIN;\n              const bottomY = gb.y + gb.h + SECTION_MARGIN;\n              let detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              detourY = _clampOutsideVNet(detourY, 'y');\n              points[i] = {{x: p1.x, y: detourY}};\n              points.splice(i + 1, 0, {{x: p2.x, y: detourY}});\n            }}\n          }} else {{\n            if (isVert) {{\n              const leftX = gb.x - SECTION_MARGIN;\n              const rightX = gb.x + gb.w + SECTION_MARGIN;\n              let newX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              newX = _clampOutsideVNet(newX, 'x');\n              points[i] = {{ x: newX, y: p1.y }};\n              points[i+1] = {{ x: newX, y: p2.y }};\n            }} else {{\n              const topY = gb.y - SECTION_MARGIN;\n              const bottomY = gb.y + gb.h + SECTION_MARGIN;\n              let newY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              newY = _clampOutsideVNet(newY, 'y');\n              points[i] = {{ x: p1.x, y: newY }};\n              points[i+1] = {{ x: p2.x, y: newY }};\n            }}\n          }}\n          break;\n        }}\n        if (found) break;\n\n        // 2) Service node obstacles\n        for (const node of NODES) {{\n          if (node.id === fromId || node.id === toId) continue;\n          const pos = positions[node.id];\n          if (!pos) continue;\n          const nw = node.type === 'pe' ? PE_W : SVC_W;\n          const nh = (node.type === 'pe' ? PE_H : SVC_H) + 20; // include text below box\n\n          if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, nw, nh, MARGIN)) continue;\n\n          found = true;\n          const isVert = Math.abs(p1.x - p2.x) \u003c 1;\n          const isFirst = (i === 0);\n          const isLast = (i + 1 === points.length - 1);\n\n          if (points.length \u003c= 2) {{\n            // Straight line hitting a node: convert to 4-point detour (anchors preserved)\n            if (isVert) {{\n              const leftX = pos.x - MARGIN;\n              const rightX = pos.x + nw + MARGIN;\n              const detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];\n            }} else {{\n              const topY = pos.y - MARGIN;\n              const bottomY = pos.y + nh + MARGIN;\n              const detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];\n            }}\n          }} else if (isFirst) {{\n            // First segment collides — keep points[0] (anchor), insert detour after it\n            if (isVert) {{\n              const leftX = pos.x - MARGIN;\n              const rightX = pos.x + nw + MARGIN;\n              const detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});\n              points[3] = {{x: detourX, y: p2.y}};\n            }} else {{\n              const topY = pos.y - MARGIN;\n              const bottomY = pos.y + nh + MARGIN;\n              const detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              points.splice(1, 0, {{x: p1.x, y: detourY}});\n              points[2] = {{x: p2.x, y: detourY}};\n            }}\n          }} else if (isLast) {{\n            // Last segment collides — keep last point (anchor), insert detour before it\n            if (isVert) {{\n              const leftX = pos.x - MARGIN;\n              const rightX = pos.x + nw + MARGIN;\n              const detourX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              points[i] = {{x: detourX, y: p1.y}};\n              points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});\n            }} else {{\n              const topY = pos.y - MARGIN;\n              const bottomY = pos.y + nh + MARGIN;\n              const detourY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              points[i] = {{x: p1.x, y: detourY}};\n              points.splice(i + 1, 0, {{x: p2.x, y: detourY}});\n            }}\n          }} else {{\n            // Middle segment: safe to push both endpoints\n            if (isVert) {{\n              const leftX = pos.x - MARGIN;\n              const rightX = pos.x + nw + MARGIN;\n              const newX = Math.abs(p1.x - leftX) \u003c= Math.abs(p1.x - rightX) ? leftX : rightX;\n              points[i] = {{ x: newX, y: p1.y }};\n              points[i+1] = {{ x: newX, y: p2.y }};\n            }} else {{\n              const topY = pos.y - MARGIN;\n              const bottomY = pos.y + nh + MARGIN;\n              const newY = Math.abs(p1.y - topY) \u003c= Math.abs(p1.y - bottomY) ? topY : bottomY;\n              points[i] = {{ x: p1.x, y: newY }};\n              points[i+1] = {{ x: p2.x, y: newY }};\n            }}\n          }}\n          break;\n        }}\n      }}\n\n      if (!found) break;\n    }}\n\n    // Restore anchors — guarantee lines always touch source/target nodes\n    points[0] = startAnchor;\n    points[points.length - 1] = endAnchor;\n\n    return points;\n  }}\n\n  // ── Edges: three-phase rendering ──\n  // Phase 0: pre-scan exit sides → Phase 1: compute paths with staggered anchors\n  // Phase 2: detect crossings → Phase 3: render with bridge arcs\n  const _edgeLabels = [];\n\n  // PHASE 0 — pre-scan: count how many edges exit each side of each node\n  const _sideTotal = {{}};\n  const _edgeSides = [];\n  EDGES.forEach(edge =\u003e {{\n    const fn = NODES.find(n =\u003e n.id === edge.from);\n    const tn = NODES.find(n =\u003e n.id === edge.to);\n    if (!fn || !tn) {{ _edgeSides.push(null); return; }}\n    const fromBox = getNodeBox(fn);\n    const toBox = getNodeBox(tn);\n    if (!fromBox || !toBox) {{ _edgeSides.push(null); return; }}\n\n    const isPeEdge = edge.type === 'private';\n    let exitSide, entrySide;\n    if (isPeEdge) {{\n      exitSide = 'bottom'; entrySide = 'top';\n    }} else {{\n      const dx = toBox.cx - fromBox.cx;\n      const dy = toBox.cy - fromBox.cy;\n      if (Math.abs(dx) \u003e= Math.abs(dy)) {{\n        exitSide = dx \u003e= 0 ? 'right' : 'left';\n        entrySide = dx \u003e= 0 ? 'left' : 'right';\n      }} else {{\n        exitSide = dy \u003e= 0 ? 'bottom' : 'top';\n        entrySide = dy \u003e= 0 ? 'top' : 'bottom';\n      }}\n    }}\n    const ek = `${{edge.from}}_${{exitSide}}`;\n    const nk = `${{edge.to}}_${{entrySide}}`;\n    _sideTotal[ek] = (_sideTotal[ek] || 0) + 1;\n    _sideTotal[nk] = (_sideTotal[nk] || 0) + 1;\n    _edgeSides.push({{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }});\n  }});\n\n  // ── RACK MARSHALLING: build channel map for inter-group edge bundling ──\n  // Step 1: map each node to its containing group box\n  const _nodeGrp = {{}};\n  NODES.forEach(n =\u003e {{\n    const pos = positions[n.id];\n    if (!pos) return;\n    const nw = n.type === 'pe' ? PE_W : SVC_W;\n    const nh = n.type === 'pe' ? PE_H : SVC_H;\n    const cx = pos.x + nw / 2, cy = pos.y + nh / 2;\n    for (let gi = 0; gi \u003c groupBoxes.length; gi++) {{\n      const gb = groupBoxes[gi];\n      if (cx \u003e= gb.x \u0026\u0026 cx \u003c= gb.x + gb.w \u0026\u0026 cy \u003e= gb.y \u0026\u0026 cy \u003c= gb.y + gb.h) {{\n        _nodeGrp[n.id] = gi; break;\n      }}\n    }}\n  }});\n\n  // Step 2: identify channels between group pairs + assign slot offsets\n  const _chMap = {{}};       // key → {{ axis:'y'|'x', value: number }}\n  const _chEdges = {{}};     // key → [edgeIdx, ...]\n  _edgeSides.forEach((info, idx) =\u003e {{\n    if (!info || info.isPeEdge) return;\n    const sg = _nodeGrp[info.edge.from], tg = _nodeGrp[info.edge.to];\n    if (sg === undefined || tg === undefined) return;\n\n    let key;\n    if (sg !== tg) {{\n      key = Math.min(sg, tg) + '_' + Math.max(sg, tg);\n      if (!_chEdges[key]) _chEdges[key] = [];\n      _chEdges[key].push(idx);\n      if (!_chMap[key]) {{\n        const a = groupBoxes[sg], b = groupBoxes[tg];\n        if (a.y + a.h \u003c= b.y)      _chMap[key] = {{ axis: 'y', value: (a.y + a.h + b.y) / 2 }};\n        else if (b.y + b.h \u003c= a.y) _chMap[key] = {{ axis: 'y', value: (b.y + b.h + a.y) / 2 }};\n        else if (a.x + a.w \u003c= b.x) _chMap[key] = {{ axis: 'x', value: (a.x + a.w + b.x) / 2 }};\n        else if (b.x + b.w \u003c= a.x) _chMap[key] = {{ axis: 'x', value: (b.x + b.w + a.x) / 2 }};\n      }}\n    }} else {{\n      // Intra-group edges: group by direction for slot offset assignment\n      const dir = (info.exitSide === 'bottom' || info.exitSide === 'top') ? 'v' : 'h';\n      key = 'i' + sg + dir;\n      if (!_chEdges[key]) _chEdges[key] = [];\n      _chEdges[key].push(idx);\n      // No fixed channel value — each edge uses its own midpoint + offset\n    }}\n  }});\n\n  // Step 3: sort edges within each channel and assign slot offsets\n  const _chOff = {{}};  // edgeIdx → offset in px\n  const _CH_SLOT = 18;  // spacing between lines in a bundle\n  Object.keys(_chEdges).forEach(key =\u003e {{\n    const ch = _chMap[key];\n    const arr = _chEdges[key];\n    const isVert = ch ? ch.axis === 'y' : key.endsWith('v');\n    arr.sort((a, b) =\u003e {{\n      const ia = _edgeSides[a], ib = _edgeSides[b];\n      if (isVert) return (ia.fromBox.cx + ia.toBox.cx) - (ib.fromBox.cx + ib.toBox.cx);\n      return (ia.fromBox.cy + ia.toBox.cy) - (ib.fromBox.cy + ib.toBox.cy);\n    }});\n    const n = arr.length;\n    arr.forEach((ei, slot) =\u003e {{\n      _chOff[ei] = n \u003e 1 ? (slot - (n - 1) / 2) * _CH_SLOT : 0;\n    }});\n  }});\n\n  // Staggered border exit: spread multiple edges evenly along node side\n  const _sideUsed = {{}};\n  function staggeredExit(nodeId, box, side) {{\n    const key = `${{nodeId}}_${{side}}`;\n    const total = _sideTotal[key] || 1;\n    const idx = _sideUsed[key] || 0;\n    _sideUsed[key] = idx + 1;\n    const isH = (side === 'top' || side === 'bottom');\n    const sideLen = isH ? box.w : box.h;\n    const CM = Math.max(40, sideLen * 0.3); // corner margin — 40px min or 30% of side\n    const usable = Math.max(0, sideLen - 2 * CM);\n    const maxSpread = Math.min(usable, total * 14);\n    const step = total \u003e 1 ? maxSpread / (total - 1) : 0;\n    const offset = total \u003e 1 ? -maxSpread / 2 + idx * step : 0;\n    if (side === 'top') return {{ x: Math.max(box.x + CM, Math.min(box.x + box.w - CM, box.cx + offset)), y: box.y }};\n    if (side === 'bottom') return {{ x: Math.max(box.x + CM, Math.min(box.x + box.w - CM, box.cx + offset)), y: box.y + box.h }};\n    if (side === 'left') return {{ x: box.x, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};\n    return {{ x: box.x + box.w, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};\n  }}\n\n  // PHASE 1 — compute edge paths with staggered anchors\n  const _allEdgePaths = [];\n  _edgeSides.forEach((info, idx) =\u003e {{\n    if (!info) return;\n    const {{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }} = info;\n    let pts;\n\n    if (isPeEdge) {{\n      const sp = staggeredExit(edge.from, fromBox, 'bottom');\n      const ep = staggeredExit(edge.to, toBox, 'top');\n      if (Math.abs(sp.x - ep.x) \u003c 8) {{\n        pts = [sp, ep];\n      }} else {{\n        let midY = (sp.y + ep.y) / 2;\n        midY = Math.max(midY, fromBox.y + fromBox.h + 40);\n        midY = Math.min(midY, toBox.y - 40);\n        pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];\n      }}\n      pts = avoidNodes(pts, edge.from, edge.to);\n    }} else {{\n      const sp = staggeredExit(edge.from, fromBox, exitSide);\n      const ep = staggeredExit(edge.to, toBox, entrySide);\n      const STUB = 40;\n\n      // Channel lookup for inter-group marshalling\n      const _sg = _nodeGrp[edge.from], _tg = _nodeGrp[edge.to];\n      const _ck = _sg !== undefined \u0026\u0026 _tg !== undefined \u0026\u0026 _sg !== _tg\n        ? Math.min(_sg, _tg) + '_' + Math.max(_sg, _tg) : null;\n      const _cc = _ck ? _chMap[_ck] : null;\n      const _co = _chOff[idx] || 0;\n\n      if (exitSide === 'right' || exitSide === 'left') {{\n        if (Math.abs(sp.y - ep.y) \u003c 8) {{\n          pts = [sp, ep];\n        }} else {{\n          let midX = (_cc \u0026\u0026 _cc.axis === 'x') ? _cc.value + _co : (sp.x + ep.x) / 2 + _co;\n          if (exitSide === 'right') midX = Math.max(midX, fromBox.x + fromBox.w + STUB);\n          if (exitSide === 'left') midX = Math.min(midX, fromBox.x - STUB);\n          if (entrySide === 'right') midX = Math.max(midX, toBox.x + toBox.w + STUB);\n          if (entrySide === 'left') midX = Math.min(midX, toBox.x - STUB);\n          pts = [sp, {{x: midX, y: sp.y}}, {{x: midX, y: ep.y}}, ep];\n        }}\n      }} else {{\n        if (Math.abs(sp.x - ep.x) \u003c 8) {{\n          pts = [sp, ep];\n        }} else {{\n          let midY = (_cc \u0026\u0026 _cc.axis === 'y') ? _cc.value + _co : (sp.y + ep.y) / 2 + _co;\n          if (exitSide === 'bottom') midY = Math.max(midY, fromBox.y + fromBox.h + STUB);\n          if (exitSide === 'top') midY = Math.min(midY, fromBox.y - STUB);\n          if (entrySide === 'bottom') midY = Math.max(midY, toBox.y + toBox.h + STUB);\n          if (entrySide === 'top') midY = Math.min(midY, toBox.y - STUB);\n          pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];\n        }}\n      }}\n\n      pts = avoidNodes(pts, edge.from, edge.to);\n    }}\n\n    // POST-ROUTING: enforce perpendicular stub at exit \u0026 entry ends\n    // 3 cases: (a) already orthogonal + long enough → skip\n    //          (b) orthogonal but short → extend existing turn point\n    //          (c) non-orthogonal → insert 2-point connector\n    const _eSide = isPeEdge ? 'bottom' : exitSide;\n    const _nSide = isPeEdge ? 'top' : entrySide;\n    const _STUB = 40;\n    if (pts.length \u003e= 3) {{\n      // --- EXIT end ---\n      const _p0 = pts[0], _p1 = pts[1];\n      const _eH = (_eSide === 'right' || _eSide === 'left');\n      if (_eH) {{\n        const _d = _eSide === 'right' ? 1 : -1;\n        const _ortho = Math.abs(_p0.y - _p1.y) \u003c= 1;\n        if (_ortho) {{\n          const _dist = (_p1.x - _p0.x) * _d;\n          if (_dist \u003c _STUB) {{\n            const sx = _p0.x + _d * _STUB;\n            pts[1] = {{x: sx, y: _p0.y}};\n            if (pts.length \u003e 2 \u0026\u0026 Math.abs(pts[2].x - _p1.x) \u003c= 1) {{\n              pts[2] = {{x: sx, y: pts[2].y}};\n            }}\n          }}\n        }} else {{\n          const sx = _p0.x + _d * _STUB;\n          pts.splice(1, 0, {{x: sx, y: _p0.y}}, {{x: sx, y: _p1.y}});\n        }}\n      }} else {{\n        const _d = _eSide === 'bottom' ? 1 : -1;\n        const _ortho = Math.abs(_p0.x - _p1.x) \u003c= 1;\n        if (_ortho) {{\n          const _dist = (_p1.y - _p0.y) * _d;\n          if (_dist \u003c _STUB) {{\n            const sy = _p0.y + _d * _STUB;\n            pts[1] = {{x: _p0.x, y: sy}};\n            if (pts.length \u003e 2 \u0026\u0026 Math.abs(pts[2].y - _p1.y) \u003c= 1) {{\n              pts[2] = {{x: pts[2].x, y: sy}};\n            }}\n          }}\n        }} else {{\n          const sy = _p0.y + _d * _STUB;\n          pts.splice(1, 0, {{x: _p0.x, y: sy}}, {{x: _p1.x, y: sy}});\n        }}\n      }}\n      // --- ENTRY end ---\n      const _pN = pts[pts.length - 1], _pP = pts[pts.length - 2];\n      const _nH = (_nSide === 'right' || _nSide === 'left');\n      if (_nH) {{\n        const _d = _nSide === 'left' ? -1 : 1;\n        const _ortho = Math.abs(_pN.y - _pP.y) \u003c= 1;\n        if (_ortho) {{\n          const _dist = (_pP.x - _pN.x) * _d;\n          if (_dist \u003c _STUB) {{\n            const sx = _pN.x + _d * _STUB;\n            const _idx = pts.length - 2;\n            pts[_idx] = {{x: sx, y: _pN.y}};\n            if (_idx \u003e 0 \u0026\u0026 Math.abs(pts[_idx - 1].x - _pP.x) \u003c= 1) {{\n              pts[_idx - 1] = {{x: sx, y: pts[_idx - 1].y}};\n            }}\n          }}\n        }} else {{\n          const sx = _pN.x + _d * _STUB;\n          pts.splice(pts.length - 1, 0, {{x: sx, y: _pP.y}}, {{x: sx, y: _pN.y}});\n        }}\n      }} else {{\n        const _d = _nSide === 'top' ? -1 : 1;\n        const _ortho = Math.abs(_pN.x - _pP.x) \u003c= 1;\n        if (_ortho) {{\n          const _dist = (_pP.y - _pN.y) * _d;\n          if (_dist \u003c _STUB) {{\n            const sy = _pN.y + _d * _STUB;\n            const _idx = pts.length - 2;\n            pts[_idx] = {{x: _pN.x, y: sy}};\n            if (_idx \u003e 0 \u0026\u0026 Math.abs(pts[_idx - 1].y - _pP.y) \u003c= 1) {{\n              pts[_idx - 1] = {{x: pts[_idx - 1].x, y: sy}};\n            }}\n          }}\n        }} else {{\n          const sy = _pN.y + _d * _STUB;\n          pts.splice(pts.length - 1, 0, {{x: _pP.x, y: sy}}, {{x: _pN.x, y: sy}});\n        }}\n      }}\n    }}\n\n    // SAFETY: break any remaining diagonal segments into orthogonal L-shapes\n    for (let _i = 0; _i \u003c pts.length - 1; _i++) {{\n      const _a = pts[_i], _b = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003e 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003e 1) {{\n        pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});\n      }}\n    }}\n\n    // SIMPLIFY: remove duplicate \u0026 collinear middle points\n    for (let _i = pts.length - 2; _i \u003e= 1; _i--) {{\n      const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003c= 1) {{\n        pts.splice(_i, 1); continue;\n      }}\n      if ((Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_b.x - _c.x) \u003c= 1) ||\n          (Math.abs(_a.y - _b.y) \u003c= 1 \u0026\u0026 Math.abs(_b.y - _c.y) \u003c= 1)) {{\n        pts.splice(_i, 1);\n      }}\n    }}\n\n    _allEdgePaths.push({{ edge, pts, isPeEdge }});\n  }});\n\n  // OVERLAP SEPARATION — shift collinear overlapping segments apart\n  // Only separate segments closer than OSEP — pre-marshalled edges (16px apart) are unaffected\n  const OSEP = 8;\n  for (let pass = 0; pass \u003c 4; pass++) {{\n    for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n      for (let j = i + 1; j \u003c _allEdgePaths.length; j++) {{\n        const pA = _allEdgePaths[i].pts;\n        const pB = _allEdgePaths[j].pts;\n        const dir = (j % 2 === 0) ? 1 : -1;\n        for (let si = 0; si \u003c pA.length - 1; si++) {{\n          for (let sj = 0; sj \u003c pB.length - 1; sj++) {{\n            const a1 = pA[si], a2 = pA[si + 1];\n            const b1 = pB[sj], b2 = pB[sj + 1];\n            const aV = Math.abs(a1.x - a2.x) \u003c 2;\n            const bV = Math.abs(b1.x - b2.x) \u003c 2;\n            const aH = Math.abs(a1.y - a2.y) \u003c 2;\n            const bH = Math.abs(b1.y - b2.y) \u003c 2;\n\n            if (aV \u0026\u0026 bV \u0026\u0026 Math.abs(a1.x - b1.x) \u003c OSEP) {{\n              const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))\n                       - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));\n              if (ov \u003e 10) {{\n                let shift = OSEP * dir;\n                if (b1.x + shift \u003c 20) shift = Math.abs(shift);\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};\n              }}\n            }}\n            if (aH \u0026\u0026 bH \u0026\u0026 Math.abs(a1.y - b1.y) \u003c OSEP) {{\n              const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))\n                       - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));\n              if (ov \u003e 10) {{\n                const shift = OSEP * dir;\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};\n              }}\n            }}\n          }}\n        }}\n      }}\n    }}\n  }}\n\n  // FINAL ORTHOGONALIZATION — fix diagonals introduced by overlap separation\n  _allEdgePaths.forEach(({{ pts }}) =\u003e {{\n    for (let _i = 0; _i \u003c pts.length - 1; _i++) {{\n      const _a = pts[_i], _b = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003e 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003e 1) {{\n        pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});\n      }}\n    }}\n    // Remove collinear\n    for (let _i = pts.length - 2; _i \u003e= 1; _i--) {{\n      const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003c= 1) {{\n        pts.splice(_i, 1); continue;\n      }}\n      if ((Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_b.x - _c.x) \u003c= 1) ||\n          (Math.abs(_a.y - _b.y) \u003c= 1 \u0026\u0026 Math.abs(_b.y - _c.y) \u003c= 1)) {{\n        pts.splice(_i, 1);\n      }}\n    }}\n  }});\n\n  // ── RE-ROUTING PASS: minimize crossings by routing edges via outer margins ──\n  // Instead of shortest-path, reroute crossing edges AROUND group boxes\n  const _gbLeft = groupBoxes.length \u003e 0 ? Math.min(...groupBoxes.map(g =\u003e g.x)) : 0;\n  const _gbRight = groupBoxes.length \u003e 0 ? Math.max(...groupBoxes.map(g =\u003e g.x + g.w)) : 800;\n  const _gbTop = groupBoxes.length \u003e 0 ? Math.min(...groupBoxes.map(g =\u003e g.y)) : 0;\n  const _gbBottom = groupBoxes.length \u003e 0 ? Math.max(...groupBoxes.map(g =\u003e g.y + g.h)) : 600;\n  const _RMARGIN = 50; // margin outside group bounds for rerouted edges\n  const _RM_SLOT = 14; // spacing between rerouted edges on the same margin\n\n  // Count H×V crossings between one edge and all others\n  function _cntCross(eIdx) {{\n    let c = 0;\n    const pA = _allEdgePaths[eIdx].pts;\n    for (let j = 0; j \u003c _allEdgePaths.length; j++) {{\n      if (j === eIdx) continue;\n      const pB = _allEdgePaths[j].pts;\n      for (let si = 0; si \u003c pA.length - 1; si++) {{\n        for (let sj = 0; sj \u003c pB.length - 1; sj++) {{\n          if (findSegCrossing(pA[si].x, pA[si].y, pA[si+1].x, pA[si+1].y,\n                              pB[sj].x, pB[sj].y, pB[sj+1].x, pB[sj+1].y)) c++;\n        }}\n      }}\n    }}\n    return c;\n  }}\n\n  // Generate a margin route: sp → stub → margin → margin → stub → ep\n  function _mRoute(sp, ep, exitSide, entrySide, side, slotOff) {{\n    const S = 40; // stub length\n    const so = slotOff || 0;\n    // stub exit from source node\n    const s1 = exitSide === 'bottom' ? {{x: sp.x, y: sp.y + S}}\n             : exitSide === 'top'    ? {{x: sp.x, y: sp.y - S}}\n             : exitSide === 'right'  ? {{x: sp.x + S, y: sp.y}}\n             :                         {{x: sp.x - S, y: sp.y}};\n    // stub entry to target node\n    const s2 = entrySide === 'top'    ? {{x: ep.x, y: ep.y - S}}\n             : entrySide === 'bottom' ? {{x: ep.x, y: ep.y + S}}\n             : entrySide === 'left'   ? {{x: ep.x - S, y: ep.y}}\n             :                          {{x: ep.x + S, y: ep.y}};\n    if (side === 'left') {{\n      const mx = _gbLeft - _RMARGIN - so;\n      return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];\n    }}\n    if (side === 'right') {{\n      const mx = _gbRight + _RMARGIN + so;\n      return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];\n    }}\n    if (side === 'top') {{\n      const my = _gbTop - _RMARGIN - so;\n      return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];\n    }}\n    // bottom\n    const my = _gbBottom + _RMARGIN + so;\n    return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];\n  }}\n\n  // Iteratively reroute edges with crossings via margins\n  const _marginUsed = {{ left: 0, right: 0, top: 0, bottom: 0 }};\n  const _tried = new Set();\n  for (let _ri = 0; _ri \u003c 30; _ri++) {{\n    // Find edge with most crossings that hasn't been tried\n    let worstIdx = -1, worstCnt = 0;\n    for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n      if (_allEdgePaths[i].isPeEdge || _tried.has(i)) continue;\n      const cnt = _cntCross(i);\n      if (cnt \u003e worstCnt) {{ worstCnt = cnt; worstIdx = i; }}\n    }}\n    if (worstIdx \u003c 0 || worstCnt === 0) break;\n\n    const ei = _edgeSides[worstIdx];\n    if (!ei) {{ _tried.add(worstIdx); continue; }}\n    const origPts = _allEdgePaths[worstIdx].pts;\n    const sp = origPts[0];\n    const ep = origPts[origPts.length - 1];\n    let bestPts = origPts, bestCnt = worstCnt, bestSide = null;\n\n    // Calculate span width for each margin to assign proper slot depth\n    for (const side of ['left', 'right', 'top', 'bottom']) {{\n      const alt = _mRoute(sp, ep, ei.exitSide, ei.entrySide, side, _marginUsed[side]);\n      _allEdgePaths[worstIdx].pts = alt;\n      const cnt = _cntCross(worstIdx);\n      _allEdgePaths[worstIdx].pts = origPts;\n      if (cnt \u003c bestCnt) {{\n        bestCnt = cnt; bestPts = alt; bestSide = side;\n      }}\n    }}\n\n    if (bestSide \u0026\u0026 bestCnt \u003c worstCnt) {{\n      _allEdgePaths[worstIdx].pts = bestPts;\n      _marginUsed[bestSide] += _RM_SLOT;\n    }} else {{\n      _tried.add(worstIdx); // mark as cannot-improve, try next edge\n    }}\n  }}\n\n  // POST-REROUTE: sort co-margin edges by span width (widest = outermost)\n  // Prevents vertical-segment crossings between edges on same margin side\n  const _marginEdges = {{ left: [], right: [], top: [], bottom: [] }};\n  for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n    const pts = _allEdgePaths[i].pts;\n    if (pts.length !== 6) continue; // only margin-routed edges have 6 points\n    // Detect which margin side this edge uses\n    const p2 = pts[2], p3 = pts[3];\n    if (p2.y === p3.y) {{\n      // horizontal segment on margin → top or bottom\n      if (p2.y \u003c _gbTop) {{ _marginEdges.top.push(i); }}\n      else if (p2.y \u003e _gbBottom) {{ _marginEdges.bottom.push(i); }}\n    }} else if (p2.x === p3.x) {{\n      // vertical segment on margin → left or right\n      if (p2.x \u003c _gbLeft) {{ _marginEdges.left.push(i); }}\n      else if (p2.x \u003e _gbRight) {{ _marginEdges.right.push(i); }}\n    }}\n  }}\n  // Sort each margin group: widest span → outermost slot\n  for (const side of ['left', 'right', 'top', 'bottom']) {{\n    const idxs = _marginEdges[side];\n    if (idxs.length \u003c 2) continue;\n    const isHoriz = (side === 'top' || side === 'bottom');\n    // Calculate span for each edge\n    const spans = idxs.map(i =\u003e {{\n      const pts = _allEdgePaths[i].pts;\n      return isHoriz\n        ? Math.abs(pts[2].x - pts[3].x)\n        : Math.abs(pts[2].y - pts[3].y);\n    }});\n    // Sort indices by span descending (widest first → outermost)\n    const sorted = idxs.map((idx, j) =\u003e ({{ idx, span: spans[j] }}))\n                       .sort((a, b) =\u003e b.span - a.span);\n    // Reassign y/x positions for sorted edges\n    const baseMargin = isHoriz\n      ? (side === 'top' ? _gbTop - _RMARGIN : _gbBottom + _RMARGIN)\n      : (side === 'left' ? _gbLeft - _RMARGIN : _gbRight + _RMARGIN);\n    const dir = (side === 'top' || side === 'left') ? -1 : 1;\n    sorted.forEach((s, k) =\u003e {{\n      const pts = _allEdgePaths[s.idx].pts;\n      const newM = baseMargin + dir * k * _RM_SLOT;\n      if (isHoriz) {{\n        pts[2].y = newM; pts[3].y = newM;\n      }} else {{\n        pts[2].x = newM; pts[3].x = newM;\n      }}\n    }});\n  }}\n\n  // BOTTOM-LANE REROUTER — marshalled U-shape approach\n  // Reroute overlapping edges via evenly-spaced horizontal lanes below all sections.\n  // Direct vertical descent from node when possible (2 bends); offset only when blocked (4 bends).\n  const OSEP2 = 14;\n  const _bottomLaneBase = _gbBottom + _RMARGIN + 30;\n  let _bottomSlot = 0;\n  const _LANE_SPC = OSEP2; // 14px lane spacing — precise marshalled look\n  const _COL_SPC = OSEP2; // minimum distance between vertical corridors\n  const _rerouted = new Set();\n  const _usedCols = [];\n  function _colUsed(cx) {{ for (const ux of _usedCols) {{ if (Math.abs(cx - ux) \u003c _COL_SPC) return true; }} return false; }}\n  // Check if vertical column is free of nodes and non-exempt section boxes\n  function _isColClear(cx, yMin, yMax, skipId1, skipId2, skipGbs) {{\n    for (const _nd of NODES) {{\n      if (_nd.id === skipId1 || _nd.id === skipId2) continue;\n      const _np = positions[_nd.id]; if (!_np) continue;\n      const _nw = _nd.type === 'pe' ? PE_W : SVC_W;\n      const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;\n      const _pad = 6;\n      if (cx \u003e _np.x - _pad \u0026\u0026 cx \u003c _np.x + _nw + _pad \u0026\u0026\n          yMin \u003c _np.y + _nh + _pad \u0026\u0026 yMax \u003e _np.y - _pad) {{\n        return false;\n      }}\n    }}\n    for (const _gb of groupBoxes) {{\n      if (skipGbs \u0026\u0026 skipGbs.indexOf(_gb) \u003e= 0) continue;\n      if (cx \u003e _gb.x - 4 \u0026\u0026 cx \u003c _gb.x + _gb.w + 4 \u0026\u0026\n          yMin \u003c _gb.y + _gb.h + 4 \u0026\u0026 yMax \u003e _gb.y - 4) {{\n        return false;\n      }}\n    }}\n    return true;\n  }}\n  // Check if horizontal row is free of nodes and non-exempt section boxes\n  function _isRowClear(cy, xMin, xMax, skipId1, skipId2, skipGbs) {{\n    for (const _nd of NODES) {{\n      if (_nd.id === skipId1 || _nd.id === skipId2) continue;\n      const _np = positions[_nd.id]; if (!_np) continue;\n      const _nw = _nd.type === 'pe' ? PE_W : SVC_W;\n      const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;\n      const _pad = 6;\n      if (cy \u003e _np.y - _pad \u0026\u0026 cy \u003c _np.y + _nh + _pad \u0026\u0026\n          xMin \u003c _np.x + _nw + _pad \u0026\u0026 xMax \u003e _np.x - _pad) {{\n        return false;\n      }}\n    }}\n    for (const _gb of groupBoxes) {{\n      if (skipGbs \u0026\u0026 skipGbs.indexOf(_gb) \u003e= 0) continue;\n      if (cy \u003e _gb.y - 4 \u0026\u0026 cy \u003c _gb.y + _gb.h + 4 \u0026\u0026\n          xMin \u003c _gb.x + _gb.w + 4 \u0026\u0026 xMax \u003e _gb.x - 4) {{\n        return false;\n      }}\n    }}\n    return true;\n  }}\n  function _findGb(px, py) {{\n    for (const _gb of groupBoxes) {{\n      if (px \u003e= _gb.x \u0026\u0026 px \u003c= _gb.x + _gb.w \u0026\u0026 py \u003e= _gb.y \u0026\u0026 py \u003c= _gb.y + _gb.h) return _gb;\n    }}\n    return null;\n  }}\n  // Find nearest clear column starting from preferred x, skipping source/dest sections\n  function _findCol(prefX, yMin, yMax, skipId1, skipId2, skipGbs, preferDir) {{\n    // Try preferred position first (direct vertical from node)\n    if (!_colUsed(prefX) \u0026\u0026 _isColClear(prefX, yMin, yMax, skipId1, skipId2, skipGbs)) return prefX;\n    // Search outward in small steps\n    const _dirs = preferDir \u003c 0 ? [-1, 1] : (preferDir \u003e 0 ? [1, -1] : [-1, 1]);\n    for (let _t = 1; _t \u003c= 100; _t++) {{\n      for (const _d of _dirs) {{\n        const _cx = prefX + _d * _t * OSEP;\n        if (_cx \u003c 20) continue;\n        if (_colUsed(_cx)) continue;\n        if (_isColClear(_cx, yMin, yMax, skipId1, skipId2, skipGbs)) return _cx;\n      }}\n    }}\n    return null;\n  }}\n  for (let _blPass = 0; _blPass \u003c 20; _blPass++) {{\n    let _worstEdge = -1, _worstCount = 0;\n    for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n      if (_rerouted.has(i)) continue;\n      let cnt = 0;\n      const pB = _allEdgePaths[i].pts;\n      for (let j = 0; j \u003c _allEdgePaths.length; j++) {{\n        if (j === i) continue;\n        const pA = _allEdgePaths[j].pts;\n        let maxOv = 0;\n        for (let si = 0; si \u003c pA.length - 1; si++) {{\n          for (let sj = 0; sj \u003c pB.length - 1; sj++) {{\n            const a1 = pA[si], a2 = pA[si + 1], b1 = pB[sj], b2 = pB[sj + 1];\n            if (Math.abs(a1.y - a2.y) \u003c 2 \u0026\u0026 Math.abs(b1.y - b2.y) \u003c 2 \u0026\u0026 Math.abs(a1.y - b1.y) \u003c OSEP2) {{\n              const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))\n                       - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));\n              if (ov \u003e maxOv) maxOv = ov;\n            }}\n            if (Math.abs(a1.x - a2.x) \u003c 2 \u0026\u0026 Math.abs(b1.x - b2.x) \u003c 2 \u0026\u0026 Math.abs(a1.x - b1.x) \u003c OSEP2) {{\n              const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))\n                       - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));\n              if (ov \u003e maxOv) maxOv = ov;\n            }}\n          }}\n        }}\n        if (maxOv \u003e 20) cnt++;\n      }}\n      if (cnt \u003e _worstCount) {{ _worstCount = cnt; _worstEdge = i; }}\n    }}\n    if (_worstEdge \u003c 0) break;\n    const pB = _allEdgePaths[_worstEdge].pts;\n    const _fromId = _allEdgePaths[_worstEdge].edge.from;\n    const _toId = _allEdgePaths[_worstEdge].edge.to;\n    const start = pB[0];\n    const end = pB[pB.length - 1];\n    // Source/dest sections are exempt — verticals can pass through own sections\n    const srcGb = _findGb(start.x, start.y);\n    const dstGb = _findGb(end.x, end.y);\n    const skipGbs = [srcGb, dstGb].filter(g =\u003e g !== null);\n    const _yMin = Math.min(start.y, end.y);\n    const _yMax = Math.max(start.y, end.y);\n    const _spanX = Math.abs(end.x - start.x);\n    // Prefer a local single-column reroute first to avoid long bottom-lane detours.\n    const _localPrefX = (start.x + end.x) / 2;\n    const _localX = _findCol(_localPrefX, _yMin, _yMax, _fromId, _toId, skipGbs);\n    const _localLimit = Math.max(_spanX + 40, 120);\n    if (_localX !== null \u0026\u0026\n        Math.abs(_localX - start.x) \u003c= _localLimit \u0026\u0026\n        Math.abs(_localX - end.x) \u003c= _localLimit \u0026\u0026\n        _isRowClear(start.y, Math.min(start.x, _localX), Math.max(start.x, _localX), _fromId, _toId, skipGbs) \u0026\u0026\n        _isRowClear(end.y, Math.min(end.x, _localX), Math.max(end.x, _localX), _fromId, _toId, skipGbs)) {{\n      _usedCols.push(_localX);\n      pB.length = 0;\n      pB.push(start);\n      if (Math.abs(_localX - start.x) \u003e 2) pB.push({{ x: _localX, y: start.y }});\n      if (Math.abs(end.y - start.y) \u003e 2) pB.push({{ x: _localX, y: end.y }});\n      if (Math.abs(_localX - end.x) \u003e 2) pB.push({{ x: _localX, y: end.y }});\n      pB.push(end);\n      _rerouted.add(_worstEdge);\n      continue;\n    }}\n    const laneY = _bottomLaneBase + _bottomSlot * _LANE_SPC;\n    const _towardEnd = end.x \u003e= start.x ? 1 : -1;\n    const _exitX = _findCol(start.x, Math.min(start.y, laneY), Math.max(start.y, laneY), _fromId, _toId, skipGbs, _towardEnd);\n    const _enterX = _findCol(end.x, Math.min(end.y, laneY), Math.max(end.y, laneY), _fromId, _toId, skipGbs, -_towardEnd);\n    if (_exitX === null || _enterX === null) {{\n      _rerouted.add(_worstEdge);\n      continue;\n    }}\n    _usedCols.push(_exitX);\n    _usedCols.push(_enterX);\n    _bottomSlot++;\n    pB.length = 0;\n    pB.push(start);\n    // Only add horizontal stub if exit column differs from node x\n    if (Math.abs(_exitX - start.x) \u003e 2) pB.push({{ x: _exitX, y: start.y }});\n    pB.push({{ x: _exitX, y: laneY }});\n    pB.push({{ x: _enterX, y: laneY }});\n    if (Math.abs(_enterX - end.x) \u003e 2) pB.push({{ x: _enterX, y: end.y }});\n    pB.push(end);\n    _rerouted.add(_worstEdge);\n  }}\n  // POST-REROUTE OVERLAP SEPARATION — push rerouted segments apart\n  for (let _rSep = 0; _rSep \u003c 6; _rSep++) {{\n    for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n      for (let j = i + 1; j \u003c _allEdgePaths.length; j++) {{\n        const pA = _allEdgePaths[i].pts;\n        const pB = _allEdgePaths[j].pts;\n        // Separate all edge pairs (rerouted or not) to handle post-reroute overlaps\n        const dir = (j % 2 === 0) ? 1 : -1;\n        for (let si = 0; si \u003c pA.length - 1; si++) {{\n          for (let sj = 0; sj \u003c pB.length - 1; sj++) {{\n            const a1 = pA[si], a2 = pA[si + 1];\n            const b1 = pB[sj], b2 = pB[sj + 1];\n            const aV = Math.abs(a1.x - a2.x) \u003c 2;\n            const bV = Math.abs(b1.x - b2.x) \u003c 2;\n            const aH = Math.abs(a1.y - a2.y) \u003c 2;\n            const bH = Math.abs(b1.y - b2.y) \u003c 2;\n            if (aV \u0026\u0026 bV \u0026\u0026 Math.abs(a1.x - b1.x) \u003c OSEP2) {{\n              const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))\n                       - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));\n              if (ov \u003e 10) {{\n                let shift = OSEP2 * dir;\n                if (b1.x + shift \u003c 20) shift = Math.abs(shift);\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};\n              }}\n            }}\n            if (aH \u0026\u0026 bH \u0026\u0026 Math.abs(a1.y - b1.y) \u003c OSEP2) {{\n              const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))\n                       - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));\n              if (ov \u003e 10) {{\n                const shift = OSEP2 * dir;\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};\n              }}\n            }}\n          }}\n        }}\n      }}\n    }}\n  }}\n  // POST-REROUTE ORTHOGONALIZATION\n  _allEdgePaths.forEach(({{ pts }}) =\u003e {{\n    for (let _i = 0; _i \u003c pts.length - 1; _i++) {{\n      const _a = pts[_i], _b = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003e 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003e 1) {{\n        pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});\n      }}\n    }}\n    for (let _i = pts.length - 2; _i \u003e= 1; _i--) {{\n      const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];\n      if (Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_a.y - _b.y) \u003c= 1) {{\n        pts.splice(_i, 1); continue;\n      }}\n      if ((Math.abs(_a.x - _b.x) \u003c= 1 \u0026\u0026 Math.abs(_b.x - _c.x) \u003c= 1) ||\n          (Math.abs(_a.y - _b.y) \u003c= 1 \u0026\u0026 Math.abs(_b.y - _c.y) \u003c= 1)) {{\n        pts.splice(_i, 1);\n      }}\n    }}\n  }});\n\n  // FINAL OVERLAP SEPARATION — catch any overlaps re-created by orthogonalization\n  for (let _fSep = 0; _fSep \u003c 4; _fSep++) {{\n    for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n      for (let j = i + 1; j \u003c _allEdgePaths.length; j++) {{\n        const pA = _allEdgePaths[i].pts;\n        const pB = _allEdgePaths[j].pts;\n        for (let si = 0; si \u003c pA.length - 1; si++) {{\n          for (let sj = 0; sj \u003c pB.length - 1; sj++) {{\n            const a1 = pA[si], a2 = pA[si + 1];\n            const b1 = pB[sj], b2 = pB[sj + 1];\n            const aH = Math.abs(a1.y - a2.y) \u003c 2;\n            const bH = Math.abs(b1.y - b2.y) \u003c 2;\n            const aV = Math.abs(a1.x - a2.x) \u003c 2;\n            const bV = Math.abs(b1.x - b2.x) \u003c 2;\n            if (aH \u0026\u0026 bH \u0026\u0026 Math.abs(a1.y - b1.y) \u003c 6) {{\n              const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))\n                       - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));\n              if (ov \u003e 20) {{\n                const shift = 8 * ((j % 2 === 0) ? 1 : -1);\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};\n              }}\n            }}\n            if (aV \u0026\u0026 bV \u0026\u0026 Math.abs(a1.x - b1.x) \u003c 6) {{\n              const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))\n                       - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));\n              if (ov \u003e 20) {{\n                let shift = 8 * ((j % 2 === 0) ? 1 : -1);\n                if (b1.x + shift \u003c 20) shift = Math.abs(shift);\n                if (sj \u003e 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};\n                if (sj + 1 \u003c pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};\n              }}\n            }}\n          }}\n        }}\n      }}\n    }}\n  }}\n\n  // FINAL DIAGONAL BREAKER — any non-orthogonal segment is split into an L-shape.\n  // Diagonals may be introduced by the separation pass above when only one\n  // endpoint of a segment is shifted. Axis-align every segment as a last safety net.\n  for (const _ep of _allEdgePaths) {{\n    const pts = _ep.pts;\n    for (let k = 0; k \u003c pts.length - 1; k++) {{\n      const q1 = pts[k], q2 = pts[k + 1];\n      const dx = q2.x - q1.x;\n      const dy = q2.y - q1.y;\n      if (Math.abs(dx) \u003e 1 \u0026\u0026 Math.abs(dy) \u003e 1) {{\n        // Insert elbow at (q2.x, q1.y) — preserves endpoints, forces L-shape.\n        // Direction heuristic: follow the dominant axis first.\n        const elbow = Math.abs(dx) \u003e= Math.abs(dy)\n          ? {{ x: q2.x, y: q1.y }}\n          : {{ x: q1.x, y: q2.y }};\n        pts.splice(k + 1, 0, elbow);\n        // Re-check the newly inserted segments in the next iteration\n      }}\n    }}\n  }}\n\n  // CROSSING DETECTION— find which edges cross each other (for color differentiation)\n  const _crossNeighbors = {{}};\n  for (let i = 0; i \u003c _allEdgePaths.length; i++) {{\n    for (let j = i + 1; j \u003c _allEdgePaths.length; j++) {{\n      const ptsA = _allEdgePaths[i].pts;\n      const ptsB = _allEdgePaths[j].pts;\n      let crossed = false;\n      for (let si = 0; si \u003c ptsA.length - 1 \u0026\u0026 !crossed; si++) {{\n        for (let sj = 0; sj \u003c ptsB.length - 1 \u0026\u0026 !crossed; sj++) {{\n          if (findSegCrossing(\n            ptsA[si].x, ptsA[si].y, ptsA[si + 1].x, ptsA[si + 1].y,\n            ptsB[sj].x, ptsB[sj].y, ptsB[sj + 1].x, ptsB[sj + 1].y\n          )) crossed = true;\n        }}\n      }}\n      if (crossed) {{\n        if (!_crossNeighbors[i]) _crossNeighbors[i] = new Set();\n        if (!_crossNeighbors[j]) _crossNeighbors[j] = new Set();\n        _crossNeighbors[i].add(j);\n        _crossNeighbors[j].add(i);\n      }}\n    }}\n  }}\n\n  // Greedy graph coloring — crossing edges get distinct colors\n  const _CROSS_COLORS = ['#0078D4', '#E3008C', '#00B7C3', '#FF8C00', '#107C10', '#881798'];\n  const _edgeColor = {{}};\n  const crossingEdges = Object.keys(_crossNeighbors).map(Number)\n    .sort((a, b) =\u003e _crossNeighbors[b].size - _crossNeighbors[a].size);\n  crossingEdges.forEach(eIdx =\u003e {{\n    const neighborColors = new Set();\n    _crossNeighbors[eIdx].forEach(n =\u003e {{\n      if (_edgeColor[n] !== undefined) neighborColors.add(_edgeColor[n]);\n    }});\n    let colorIdx = 0;\n    while (neighborColors.has(colorIdx)) colorIdx++;\n    _edgeColor[eIdx] = colorIdx;\n  }});\n\n  // RENDER EDGES — no bridge arcs, just orthogonal paths with color coding\n\n  function renderEdge({{ edge, pts, isPeEdge, edgeIdx }}) {{\n    let pathD;\n    if (pts.length \u003c= 2) {{\n      pathD = `M ${{pts[0].x}} ${{pts[0].y}} L ${{pts[pts.length - 1].x}} ${{pts[pts.length - 1].y}}`;\n    }} else {{\n      pathD = buildOrthoPath(pts);\n    }}\n\n    // Determine edge color: PE=purple, crossing=colored, normal=gray\n    let edgeStroke, edgeOpacity;\n    if (isPeEdge) {{\n      edgeStroke = '#5C2D91';\n      edgeOpacity = '0.5';\n    }} else if (_edgeColor[edgeIdx] !== undefined) {{\n      edgeStroke = _CROSS_COLORS[_edgeColor[edgeIdx] % _CROSS_COLORS.length];\n      edgeOpacity = '0.75';\n    }} else {{\n      edgeStroke = '#8a8886';\n      edgeOpacity = '0.65';\n    }}\n\n    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n    path.setAttribute('d', pathD);\n    path.setAttribute('fill', 'none');\n    path.setAttribute('stroke', edgeStroke);\n    path.setAttribute('stroke-width', isPeEdge ? '1' : '1.2');\n    path.setAttribute('stroke-dasharray', edge.dash || '0');\n    path.setAttribute('marker-end', `url(#${{markerFor(edge.type)}})`);\n    path.setAttribute('opacity', edgeOpacity);\n    path.classList.add('edge-path');\n    path.setAttribute('data-from', edge.from);\n    path.setAttribute('data-to', edge.to);\n    root.appendChild(path);\n\n    // Label placement — collision-aware\n    if (edge.label) {{\n      const bw = edge.label.length * 5.5 + 10;\n      const bh = 14;\n\n      function labelHitsNode(lx, ly) {{\n        return NODES.some(n =\u003e {{\n          const p = positions[n.id];\n          if (!p) return false;\n          const nw = n.type === 'pe' ? PE_W : SVC_W;\n          const nh = n.type === 'pe' ? PE_H : SVC_H;\n          return lx + bw/2 \u003e p.x \u0026\u0026 lx - bw/2 \u003c p.x + nw\n              \u0026\u0026 ly + bh/2 \u003e p.y \u0026\u0026 ly - bh/2 \u003c p.y + nh;\n        }});\n      }}\n\n      const candidates = [];\n      for (let s = 0; s \u003c pts.length - 1; s++) {{\n        const cx = (pts[s].x + pts[s + 1].x) / 2;\n        const cy = (pts[s].y + pts[s + 1].y) / 2;\n        const priority = Math.abs(s - (pts.length - 2) / 2);\n        candidates.push({{ x: cx, y: cy, priority }});\n      }}\n      candidates.sort((a, b) =\u003e a.priority - b.priority);\n\n      let chosen = candidates[0];\n      for (const c of candidates) {{\n        if (!labelHitsNode(c.x, c.y)) {{ chosen = c; break; }}\n      }}\n\n      if (labelHitsNode(chosen.x, chosen.y)) {{\n        const offsets = [{{x:0,y:-20}},{{x:0,y:20}},{{x:-20,y:0}},{{x:20,y:0}}];\n        for (const off of offsets) {{\n          if (!labelHitsNode(chosen.x + off.x, chosen.y + off.y)) {{\n            chosen = {{ x: chosen.x + off.x, y: chosen.y + off.y }};\n            break;\n          }}\n        }}\n      }}\n\n      _edgeLabels.push({{ label: edge.label, x: chosen.x, y: chosen.y, from: edge.from, to: edge.to }});\n    }}\n\n    return {{ path, edge, pts }};\n  }}\n\n  // Render all edges\n  _allEdgePaths.forEach((ep, edgeIdx) =\u003e renderEdge({{ ...ep, edgeIdx }}));\n\n  // Re-append group labels on top of edges\n  _groupLabelElements.forEach(el =\u003e root.appendChild(el));\n\n  // ── Nodes (rendered LAST — on top of edges, covering crossing points) ──\n  NODES.forEach(node =\u003e {{\n    const pos = positions[node.id];\n    if (!pos) return;\n    const isPe = node.type === 'pe';\n    const nw = isPe ? PE_W : SVC_W;\n    const nh = isPe ? PE_H : SVC_H;\n    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');\n    g.setAttribute('class', 'node');\n    g.setAttribute('data-id', node.id);\n    g.setAttribute('transform', `translate(${{pos.x}},${{pos.y}})`);\n\n    // Card background — full clickable area\n    const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    rect.setAttribute('class', 'node-bg');\n    rect.setAttribute('width', nw); rect.setAttribute('height', nh);\n    rect.setAttribute('rx', '8'); rect.setAttribute('fill', 'white');\n    rect.setAttribute('stroke', '#c8c6c4'); rect.setAttribute('stroke-width', '1.2');\n    rect.setAttribute('filter', 'url(#shadow)');\n    g.appendChild(rect);\n\n    // Color accent bar at top\n    const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    accent.setAttribute('width', nw); accent.setAttribute('height', '3');\n    accent.setAttribute('rx', '8'); accent.setAttribute('fill', node.color);\n    accent.setAttribute('opacity', '0.7');\n    g.appendChild(accent);\n\n    // Icon — official Azure icon (data URI) preferred, fallback to SVG\n    const iconSize = isPe ? 28 : 36;\n    const iconX = (nw - iconSize) / 2;\n    const iconY = isPe ? 12 : 14;\n    if (node.icon_data_uri) {{\n      // Official Azure icon (Base64 image)\n      const iconImg = document.createElementNS('http://www.w3.org/2000/svg', 'image');\n      iconImg.setAttribute('x', iconX); iconImg.setAttribute('y', iconY);\n      iconImg.setAttribute('width', iconSize); iconImg.setAttribute('height', iconSize);\n      iconImg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', node.icon_data_uri);\n      g.appendChild(iconImg);\n    }} else {{\n      // Fallback: built-in SVG text icon\n      const iconG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n      iconG.setAttribute('x', iconX); iconG.setAttribute('y', iconY);\n      iconG.setAttribute('width', iconSize); iconG.setAttribute('height', iconSize);\n      iconG.setAttribute('viewBox', '0 0 48 48');\n      iconG.innerHTML = node.icon_svg;\n      g.appendChild(iconG);\n    }}\n\n    // Name — extra gap below icon (icon bottom ~50, name baseline at 74 → 24px breathing room)\n    const name = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n    name.setAttribute('x', nw/2); name.setAttribute('y', isPe ? 64 : 74);\n    name.setAttribute('text-anchor', 'middle');\n    name.setAttribute('font-size', isPe ? '10' : '11');\n    name.setAttribute('font-weight', '600'); name.setAttribute('fill', '#323130');\n    name.setAttribute('font-family', 'Segoe UI, sans-serif');\n    const maxC = isPe ? 14 : 20;\n    name.textContent = node.name.length \u003e maxC ? node.name.substring(0, maxC-1) + '..' : node.name;\n    g.appendChild(name);\n\n    // SKU label\n    if (!isPe \u0026\u0026 node.sku) {{\n      const sku = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n      sku.setAttribute('x', nw/2); sku.setAttribute('y', 90);\n      sku.setAttribute('text-anchor', 'middle');\n      sku.setAttribute('font-size', '10'); sku.setAttribute('fill', '#a19f9d');\n      sku.setAttribute('font-family', 'Segoe UI, sans-serif');\n      sku.textContent = node.sku;\n      g.appendChild(sku);\n    }}\n\n    if (isPe \u0026\u0026 node.details.length \u003e 0) {{\n      const det = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n      det.setAttribute('x', nw/2); det.setAttribute('y', 76);\n      det.setAttribute('text-anchor', 'middle');\n      det.setAttribute('font-size', '9'); det.setAttribute('fill', '#a19f9d');\n      det.setAttribute('font-family', 'Segoe UI, sans-serif');\n      det.textContent = node.details[0];\n      g.appendChild(det);\n    }}\n\n    // Service type label below (not category — show actual service type name)\n    if (!isPe) {{\n      const TYPE_LABELS = {{\n        'ai_foundry': 'AI Foundry', 'openai': 'Azure OpenAI', 'search': 'AI Search', 'ai_search': 'AI Search',\n        'storage': 'Storage', 'adls': 'ADLS Gen2', 'keyvault': 'Key Vault', 'kv': 'Key Vault',\n        'fabric': 'Fabric', 'databricks': 'Databricks', 'adf': 'Data Factory', 'data_factory': 'Data Factory',\n        'sql_server': 'SQL Server', 'sql_database': 'SQL Database', 'cosmos_db': 'Cosmos DB',\n        'vm': 'Virtual Machine', 'aks': 'AKS', 'app_service': 'App Service',\n        'function_app': 'Function App', 'synapse': 'Synapse', 'vnet': 'VNet',\n        'nsg': 'NSG', 'bastion': 'Bastion', 'pe': 'Private Endpoint',\n        'log_analytics': 'Log Analytics', 'app_insights': 'App Insights',\n        'monitor': 'Monitor', 'acr': 'Container Registry', 'container_registry': 'Container Registry',\n        'document_intelligence': 'Doc Intelligence', 'form_recognizer': 'Doc Intelligence',\n        'cdn': 'CDN', 'event_hub': 'Event Hub', 'redis': 'Redis Cache',\n        'devops': 'Azure DevOps', 'app_gateway': 'App Gateway',\n        'iot_hub': 'IoT Hub', 'stream_analytics': 'Stream Analytics',\n        'vpn_gateway': 'VPN Gateway', 'front_door': 'Front Door',\n        'ai_hub': 'AI Hub', 'firewall': 'Firewall',\n      }};\n      const typeLabel = TYPE_LABELS[node.type] || node.type;\n      const cat = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n      cat.setAttribute('x', nw/2); cat.setAttribute('y', nh + 14);\n      cat.setAttribute('text-anchor', 'middle');\n      cat.setAttribute('font-size', '10'); cat.setAttribute('fill', node.color);\n      cat.setAttribute('font-weight', '600');\n      cat.setAttribute('font-family', 'Segoe UI, sans-serif');\n      cat.textContent = typeLabel;\n      g.appendChild(cat);\n    }}\n\n    // Private badge on card\n    if (node.private \u0026\u0026 !isPe) {{\n      const badge = document.createElementNS('http://www.w3.org/2000/svg', 'g');\n      const br = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n      br.setAttribute('x', nw - 8); br.setAttribute('y', '4');\n      br.setAttribute('width', '6'); br.setAttribute('height', '6');\n      br.setAttribute('rx', '3'); br.setAttribute('fill', '#5C2D91');\n      br.setAttribute('opacity', '0.6');\n      badge.appendChild(br);\n      g.appendChild(badge);\n    }}\n\n    // ── Events: drag vs click separation ──\n    g.addEventListener('mousedown', e =\u003e {{\n      if (e.button !== 0) return;\n      dragging = node.id;\n      _didDrag = false;\n      _dragStartX = e.clientX; _dragStartY = e.clientY;\n      const svgPt = getSVGPoint(e);\n      dragOffX = svgPt.x - pos.x; dragOffY = svgPt.y - pos.y;\n      e.stopPropagation(); e.preventDefault();\n    }});\n    g.addEventListener('mousemove', e =\u003e {{\n      if (dragging === node.id) {{\n        const dx = Math.abs(e.clientX - _dragStartX);\n        const dy = Math.abs(e.clientY - _dragStartY);\n        if (dx \u003e 3 || dy \u003e 3) _didDrag = true;\n      }}\n    }});\n    g.addEventListener('mouseup', e =\u003e {{\n      if (!_didDrag \u0026\u0026 dragging === node.id) {{\n        selectNode(node.id);\n      }}\n    }});\n    g.addEventListener('mouseenter', e =\u003e {{\n      const tt = document.getElementById('tooltip');\n      const dets = node.details.map(d =\u003e `\u003cdiv class=\"tooltip-detail\"\u003e› ${{d}}\u003c/div\u003e`).join('');\n      tt.style.display = 'block';\n      tt.innerHTML = `\u003cstrong\u003e${{node.name}}\u003c/strong\u003e${{node.sku ? `\u003cdiv class=\"tooltip-detail\"\u003eSKU: ${{node.sku}}\u003c/div\u003e` : ''}}${{dets}}`;\n    }});\n    g.addEventListener('mousemove', e =\u003e {{\n      const tt = document.getElementById('tooltip');\n      tt.style.left = (e.clientX+12)+'px'; tt.style.top = (e.clientY-8)+'px';\n    }});\n    g.addEventListener('mouseleave', () =\u003e {{ document.getElementById('tooltip').style.display = 'none'; }});\n\n    root.appendChild(g);\n  }});\n\n  // ── Edge labels (rendered AFTER nodes — always visible on top) ──\n  _edgeLabels.forEach(el =\u003e {{\n    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');\n    g.classList.add('edge-label');\n    g.setAttribute('data-from', el.from);\n    g.setAttribute('data-to', el.to);\n    const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n    r.classList.add('edge-label-bg');\n    const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n    const bw = el.label.length * 6 + 10;\n    r.setAttribute('x', el.x-bw/2); r.setAttribute('y', el.y-7);\n    r.setAttribute('width', bw); r.setAttribute('height', 14);\n    r.setAttribute('rx', '3'); r.setAttribute('fill', 'white');\n    r.setAttribute('stroke', '#d2d0ce'); r.setAttribute('stroke-width', '0.5');\n    r.setAttribute('opacity', '0.95');\n    t.setAttribute('x', el.x); t.setAttribute('y', el.y+3);\n    t.setAttribute('text-anchor', 'middle'); t.setAttribute('font-size', '9');\n    t.setAttribute('fill', '#605e5c'); t.setAttribute('font-family', 'Segoe UI, sans-serif');\n    t.textContent = el.label;\n    g.appendChild(r); g.appendChild(t);\n    root.appendChild(g);\n  }});\n\n  // Re-apply text scale and selection state after DOM rebuild\n  if (typeof _textScale !== 'undefined' \u0026\u0026 _textScale !== 1) applyTextScale();\n  if (_selectedNodeId) applySelectionHighlight();\n\n}}\n\nfunction getSVGPoint(e) {{\n  const svg = document.getElementById('canvas');\n  const pt = svg.createSVGPoint();\n  pt.x = e.clientX; pt.y = e.clientY;\n  return pt.matrixTransform(document.getElementById('diagram-root').getScreenCTM().inverse());\n}}\n\ndocument.getElementById('canvas').addEventListener('mousemove', e =\u003e {{\n  if (dragging) {{\n    const p = getSVGPoint(e);\n    positions[dragging].x = p.x - dragOffX;\n    positions[dragging].y = p.y - dragOffY;\n    renderDiagram();\n  }} else if (draggingGroup !== null) {{\n    const p = getSVGPoint(e);\n    const dx = p.x - dragOffX;\n    const dy = p.y - dragOffY;\n    dragOffX = p.x; dragOffY = p.y;\n    // Move all nodes in the group\n    groupDragNodes.forEach(nid =\u003e {{\n      if (positions[nid]) {{\n        positions[nid].x += dx;\n        positions[nid].y += dy;\n      }}\n    }});\n    // Also move the group box itself\n    const gb = groupBoxes[draggingGroup];\n    if (gb) {{ gb.x += dx; gb.y += dy; }}\n    renderDiagram();\n  }}\n}});\ndocument.addEventListener('mouseup', () =\u003e {{ dragging = null; draggingGroup = null; groupDragNodes = []; }});\n\n// ── Pan \u0026 Zoom ──\nfunction applyTransform() {{\n  document.getElementById('diagram-root').setAttribute('transform',\n    `translate(${{viewTransform.x}},${{viewTransform.y}}) scale(${{viewTransform.scale}})`);\n  document.getElementById('zoom-level').textContent = Math.round(viewTransform.scale * 100) + '%';\n}}\nfunction fitToScreen() {{\n  const svg = document.getElementById('canvas');\n  const root = document.getElementById('diagram-root');\n  root.setAttribute('transform', '');\n  const bbox = root.getBBox();\n  if (!bbox.width || !bbox.height) return;\n  const w = svg.clientWidth, h = svg.clientHeight;\n  const s = Math.min((w-60)/bbox.width, (h-60)/bbox.height, 1.5);\n  if (s \u003c= 0) return;\n  viewTransform.scale = s;\n  viewTransform.x = (w - bbox.width*s)/2 - bbox.x*s;\n  viewTransform.y = (h - bbox.height*s)/2 - bbox.y*s;\n  applyTransform();\n}}\nfunction zoomIn() {{ viewTransform.scale *= 1.25; applyTransform(); }}\nfunction zoomOut() {{ viewTransform.scale *= 0.8; applyTransform(); }}\n\n// ── Text size controls ──\nlet _textScale = 1.4;  // default 40% larger than raw attribute sizes\nfunction applyTextScale() {{\n  document.querySelectorAll('#canvas text').forEach(t =\u003e {{\n    let orig = t.getAttribute('data-orig-fs');\n    if (!orig) {{\n      orig = t.getAttribute('font-size');\n      if (!orig) {{\n        const cs = window.getComputedStyle(t).fontSize;\n        orig = cs ? parseFloat(cs).toString() : '11';\n      }}\n      t.setAttribute('data-orig-fs', orig);\n    }}\n    t.setAttribute('font-size', (parseFloat(orig) * _textScale).toFixed(2));\n  }});\n}}\nfunction textBigger() {{ _textScale = Math.min(2.5, _textScale * 1.15); applyTextScale(); }}\nfunction textSmaller() {{ _textScale = Math.max(0.5, _textScale / 1.15); applyTextScale(); }}\n\nfunction downloadPNG() {{\n  const svg = document.getElementById('canvas');\n  const bbox = svg.getBBox();\n  const pad = 40;\n  const w = Math.ceil(bbox.width + bbox.x + pad * 2);\n  const h = Math.ceil(bbox.height + bbox.y + pad * 2);\n\n  const clone = svg.cloneNode(true);\n  clone.setAttribute('width', w);\n  clone.setAttribute('height', h);\n  clone.setAttribute('viewBox', `${{-pad}} ${{-pad}} ${{w}} ${{h}}`);\n  clone.querySelector('#viewport')?.removeAttribute('transform');\n\n  // Inline all computed styles\n  const allEls = clone.querySelectorAll('*');\n  const origEls = svg.querySelectorAll('*');\n  allEls.forEach((el, i) =\u003e {{\n    if (origEls[i]) {{\n      const cs = window.getComputedStyle(origEls[i]);\n      ['fill','stroke','stroke-width','font-size','font-family','font-weight',\n       'text-anchor','opacity','fill-opacity','stroke-opacity','stroke-dasharray'].forEach(p =\u003e {{\n        const v = cs.getPropertyValue(p);\n        if (v) el.style.setProperty(p, v);\n      }});\n    }}\n  }});\n\n  const serializer = new XMLSerializer();\n  const svgStr = serializer.serializeToString(clone);\n  const svgBlob = new Blob([svgStr], {{type: 'image/svg+xml;charset=utf-8'}});\n  const url = URL.createObjectURL(svgBlob);\n\n  const img = new Image();\n  img.onload = () =\u003e {{\n    const canvas = document.createElement('canvas');\n    const scale = 2;\n    canvas.width = w * scale;\n    canvas.height = h * scale;\n    const ctx = canvas.getContext('2d');\n    ctx.scale(scale, scale);\n    ctx.fillStyle = '#ffffff';\n    ctx.fillRect(0, 0, w, h);\n    ctx.drawImage(img, 0, 0, w, h);\n    URL.revokeObjectURL(url);\n\n    canvas.toBlob(blob =\u003e {{\n      const a = document.createElement('a');\n      a.href = URL.createObjectURL(blob);\n      a.download = (document.title || 'azure-architecture') + '.png';\n      a.click();\n      URL.revokeObjectURL(a.href);\n    }}, 'image/png');\n  }};\n  img.src = url;\n}}\n\ndocument.getElementById('canvas').addEventListener('wheel', e =\u003e {{\n  e.preventDefault();\n  const f = e.deltaY \u003c 0 ? 1.1 : 0.9;\n  const rect = document.getElementById('canvas').getBoundingClientRect();\n  const mx = e.clientX - rect.left, my = e.clientY - rect.top;\n  const os = viewTransform.scale, ns = os * f;\n  viewTransform.x = mx - (mx - viewTransform.x) * (ns/os);\n  viewTransform.y = my - (my - viewTransform.y) * (ns/os);\n  viewTransform.scale = ns;\n  applyTransform();\n}}, {{ passive: false }});\n\ndocument.getElementById('canvas').addEventListener('mousedown', e =\u003e {{\n  if (e.target.closest('.node')) return;\n  isPanning = true;\n  panSX = e.clientX; panSY = e.clientY;\n  panSTx = viewTransform.x; panSTy = viewTransform.y;\n  document.getElementById('canvas').style.cursor = 'grabbing';\n  e.preventDefault();\n}});\ndocument.addEventListener('mousemove', e =\u003e {{\n  if (isPanning) {{\n    viewTransform.x = panSTx + (e.clientX - panSX);\n    viewTransform.y = panSTy + (e.clientY - panSY);\n    applyTransform();\n  }}\n}});\ndocument.addEventListener('mouseup', () =\u003e {{\n  if (isPanning) {{ isPanning = false; document.getElementById('canvas').style.cursor = ''; }}\n}});\n\n// ── Sidebar ──\nfunction buildSidebar() {{\n  const list = document.getElementById('service-list');\n  const byCat = {{}};\n  NODES.forEach(n =\u003e {{ if (!byCat[n.category]) byCat[n.category] = []; byCat[n.category].push(n); }});\n  Object.entries(byCat).forEach(([cat, nodes]) =\u003e {{\n    const cd = document.createElement('div');\n    cd.className = 'cat-label'; cd.textContent = cat;\n    list.appendChild(cd);\n    nodes.forEach(node =\u003e {{\n      const card = document.createElement('div');\n      card.className = 'service-card'; card.id = 'card-' + node.id;\n      card.innerHTML = `\n        \u003cdiv class=\"service-card-header\"\u003e\n          \u003cdiv class=\"sc-icon\"\u003e${{node.icon_data_uri ? `\u003cimg src=\"${{node.icon_data_uri}}\" width=\"28\" height=\"28\" style=\"object-fit:contain;\"\u003e` : `\u003csvg viewBox=\"0 0 48 48\"\u003e${{node.icon_svg}}\u003c/svg\u003e`}}\u003c/div\u003e\n          \u003cdiv\u003e\n            \u003cdiv class=\"service-name\"\u003e${{node.name}}\u003c/div\u003e\n            \u003cdiv class=\"service-sku\"\u003e${{node.sku || node.type}}\u003c/div\u003e\n          \u003c/div\u003e\n          ${{node.private ? '\u003cspan class=\"private-badge\"\u003ePrivate\u003c/span\u003e' : ''}}\n        \u003c/div\u003e\n        ${{node.details.length \u003e 0 ? `\u003cdiv class=\"service-card-body\"\u003e${{node.details.map(d =\u003e `\u003cdiv class=\"service-detail\"\u003e${{d}}\u003c/div\u003e`).join('')}}\u003c/div\u003e` : ''}}\n      `;\n      card.addEventListener('click', () =\u003e {{\n        selectNode(node.id);\n      }});\n      list.appendChild(card);\n    }});\n  }});\n}}\n\n// ── VNet highlight toggle ──\nlet _vnetHighlighted = false;\nfunction toggleVNetHighlight() {{\n  _vnetHighlighted = !_vnetHighlighted;\n  const vr = document.getElementById('vnet-rect');\n  if (!vr) return;\n  if (_vnetHighlighted) {{\n    vr.setAttribute('stroke-width', '4');\n    vr.setAttribute('stroke', '#5C2D91');\n    vr.setAttribute('fill', '#f0eaf8');\n  }} else {{\n    vr.setAttribute('stroke-width', '2');\n    vr.setAttribute('stroke', '#5C2D91');\n    vr.setAttribute('fill', '#f8f7ff');\n  }}\n  // Also toggle sidebar card\n  const card = document.getElementById('card-vnet-boundary');\n  if (card) card.classList.toggle('selected', _vnetHighlighted);\n}}\n\nrenderDiagram();\nbuildSidebar();\n\n// ── VNet sidebar card (added dynamically if VNet boundary exists) ──\nif (VNET_INFO || NODES.some(n =\u003e n.private \u0026\u0026 n.type !== 'pe') || NODES.some(n =\u003e n.type === 'pe')) {{\n  const list = document.getElementById('service-list');\n  // Insert at the top\n  const catLabel = document.createElement('div');\n  catLabel.className = 'cat-label'; catLabel.textContent = 'NETWORK';\n  const card = document.createElement('div');\n  card.className = 'service-card'; card.id = 'card-vnet-boundary';\n  const vnetIcon = '\u003crect x=\"6\" y=\"6\" width=\"36\" height=\"36\" rx=\"4\" fill=\"none\" stroke=\"#5C2D91\" stroke-width=\"3\"/\u003e\u003ccircle cx=\"16\" cy=\"18\" r=\"3\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"32\" cy=\"18\" r=\"3\" fill=\"#5C2D91\"/\u003e\u003ccircle cx=\"24\" cy=\"32\" r=\"3\" fill=\"#5C2D91\"/\u003e';\n  const vnetDetails = VNET_INFO ? VNET_INFO.split('|').map(s =\u003e s.trim()) : [];\n  card.innerHTML = `\n    \u003cdiv class=\"service-card-header\"\u003e\n      \u003cdiv class=\"sc-icon\"\u003e\u003csvg viewBox=\"0 0 48 48\"\u003e${{vnetIcon}}\u003c/svg\u003e\u003c/div\u003e\n      \u003cdiv\u003e\n        \u003cdiv class=\"service-name\"\u003eVirtual Network\u003c/div\u003e\n        \u003cdiv class=\"service-sku\"\u003evnet\u003c/div\u003e\n      \u003c/div\u003e\n      \u003cspan class=\"private-badge\"\u003ePrivate\u003c/span\u003e\n    \u003c/div\u003e\n    ${{vnetDetails.length \u003e 0 ? `\u003cdiv class=\"service-card-body\"\u003e${{vnetDetails.map(d =\u003e `\u003cdiv class=\"service-detail\"\u003e${{d}}\u003c/div\u003e`).join('')}}\u003c/div\u003e` : ''}}\n  `;\n  card.addEventListener('click', () =\u003e {{ toggleVNetHighlight(); }});\n  list.insertBefore(card, list.firstChild);\n  list.insertBefore(catLabel, list.firstChild);\n}}\nsetTimeout(fitToScreen, 100);\n\u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\"\"\"\n    return html\n\ndef generate_diagram(services, connections, title=\"Azure Architecture\", vnet_info=\"\", hierarchy=None):\n    \"\"\"Generate an interactive Azure architecture diagram as an HTML string.\n\n    Args:\n        services: list of dicts with keys id, name, type, sku, private, details, etc.\n        connections: list of dicts with keys from, to, label, type.\n        title: diagram title string.\n        vnet_info: VNet CIDR info string.\n        hierarchy: optional subscription/RG hierarchy list.\n\n    Returns:\n        HTML string containing the interactive diagram.\n    \"\"\"\n    return generate_html(services, connections, title, vnet_info=vnet_info, hierarchy=hierarchy)\n"},"import":{"commit_sha":"541b7819d8c3545c6df122491af4fa1eae415779","imported_at":"2026-05-18T20:07:09Z","license_text":"MIT License\n\nCopyright GitHub, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.","owner":"github","repo":"github/awesome-copilot","source_url":"https://github.com/github/awesome-copilot/tree/541b7819d8c3545c6df122491af4fa1eae415779/skills/azure-architecture-autopilot"}},"content_hash":[197,238,158,221,47,185,118,135,44,73,22,91,91,229,22,40,173,196,107,239,47,31,192,21,163,27,192,244,217,156,164,243],"trust_level":"unsigned","yanked":false}
