From fb96e4519b9617330cc1cf150855214982604409 Mon Sep 17 00:00:00 2001 From: chancecontra Date: Wed, 5 Aug 2026 14:26:48 +0200 Subject: [PATCH 1/5] Add skill submission for create-copilot-adoption-dashboard --- .../README.md | 106 +++ .../SKILL.md | 166 +++++ .../assets/dashboard_template.html | 656 +++++++++++++++++ .../metadata.json | 11 + .../references/adoption-framework.md | 191 +++++ .../scripts/build_dashboard.py | 672 ++++++++++++++++++ 6 files changed, 1802 insertions(+) create mode 100644 submissions/create-copilot-adoption-dashboard/README.md create mode 100644 submissions/create-copilot-adoption-dashboard/SKILL.md create mode 100644 submissions/create-copilot-adoption-dashboard/assets/dashboard_template.html create mode 100644 submissions/create-copilot-adoption-dashboard/metadata.json create mode 100644 submissions/create-copilot-adoption-dashboard/references/adoption-framework.md create mode 100644 submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py diff --git a/submissions/create-copilot-adoption-dashboard/README.md b/submissions/create-copilot-adoption-dashboard/README.md new file mode 100644 index 00000000..ea178bc2 --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/README.md @@ -0,0 +1,106 @@ +# Copilot Adoption Dashboard + +Turns the four Microsoft admin center Copilot activity exports into a branded, +single-file HTML adoption dashboard — KPIs, user segments, surface and agent +usage, the conversion pipeline, and recommended next steps. + +## Before you start + +Nothing to install — the skill runs on the Python that ships with Cowork and +uses only the standard library. No connected account, no special permissions, +no network access needed at build time. + +What you do need is **four CSV exports** from the Microsoft 365 admin center +(**Reports → Usage → Microsoft 365 Copilot**, then *Export*). Pulling reports +there requires an admin role that can read Copilot usage data — typically Reports +Reader, Usage Summary Reports Reader, or a Global/Copilot admin. If you can open +the Copilot usage report in the admin center, you can export what this needs: + +| # | Report | What it carries | +|---|---|---| +| 1 | **Copilot usage** | One row per licensed user — prompts, active days, per-app activity | +| 2 | **Agents — per user** | Agents used and responses received, per user | +| 3 | **Agents — per agent** | One row per agent — active users, responses sent | +| 4 | **Copilot Chat** | Chat activity, **including users with no paid licence** | + +Bring all four for the full picture. Fewer is fine — the dashboard renders what +it can and marks the rest as pending rather than guessing. + +Optional: a **logo** file (PNG, JPG, SVG, GIF or WebP). It gets embedded into the +HTML, so the finished dashboard stays a single portable file. + +## How to use it + +Run the command, or just ask in plain language: + +``` +/create-copilot-adoption-dashboard +``` + +> "Create a Copilot adoption dashboard for Contoso" +> "Turn these MAC exports into an adoption dashboard" +> "Build my Copilot usage dashboard — company is Fabrikam, logo attached" + +The skill will explain the process, ask for your **company name** (required) and +**logo** (optional), and pick up the CSVs you've attached. It works out which +file is which by reading the column headers, so you don't need to name them in +any particular way. + +It then computes the real numbers and hands back one HTML file — +`_Copilot_Adoption_Dashboard.html` — that opens in any browser: + +- **Four KPI tiles** — licence activation, habitual-user rate, agent adoption, and conversion-ready leads. +- **Users by segment** — Champion, Habitual, Casual, Dormant, Never activated. +- **Top surfaces** — the ten apps where Copilot is actually being used. +- **Agent usage share** — the ten agents with the most reach. +- **Conversion funnel** — unlicensed Chat users ranked into Warm and Hot leads. +- **Recommended next steps** — the enablement plays that apply to *your* numbers. + +Every tile and panel has an **i** button explaining what the metric means, how +it's calculated, and the target it's measured against. Hovering the donut or any +bar shows the underlying head-count, not just the percentage. + +Before it delivers, it shows you a summary of the computed KPIs so you can sanity +-check them. You can also ask for a specific reporting window or report date, or +rerun with a corrected company name — just say so. + +## Good to know + +**The reporting window comes from your data.** The skill reads the *Report +Period* column (the admin center default is 28 days) and states that window on +the dashboard. If your exports disagree with each other, or the column is +missing, it tells you which window it used and why. Only override it if you +genuinely want a different period. + +**Recommendations are earned, not generic.** Two kinds appear. *Triggered by your +KPI values* cards show up only when a number misses its framework target, and +each one quotes the number that triggered it. *Ongoing recommendations* — where +to focus training (your strongest surfaces and agents) and awareness (the quiet +ones) — always appear, because they're steady-state advice rather than a threshold +breach. If every KPI passes, you get "You're doing great — keep it up!" with the +ongoing cards beneath it. + +**Missing a report is safe.** Leave one out and the related panel renders as +*pending* with a note naming what's needed. Nothing is ever estimated or filled +in with sample data — if the skill can't compute something, it says so. + +**Zero-percent surfaces are shown on purpose.** An app sitting at 0% isn't a bug; +it's a licensed capability nobody is using, which is exactly where targeted +enablement pays off. Ask if you'd rather hide them. + +**It's a single-period snapshot.** Growth, retention and dormancy trends need two +exports to compare, so they aren't reported. + +**Unusual column names are handled.** Headers are matched by keyword, not exact +text, so tenant-to-tenant differences are usually absorbed. If a column genuinely +can't be matched, the skill reports which one and can be pointed at the right +header — it will never quietly substitute a different number. + +**Privacy.** The dashboard is aggregate only — segment counts, percentages and +totals. No individual user rows, names or rankings appear in the output, and the +skill won't evaluate individual people's usage. Due to this, you can use anonimized report exports as well. + +**Sharing it.** The result is one self-contained file with the logo embedded and +no external dependencies, so it emails and archives cleanly. It's a point-in-time +snapshot, not a live report — rerun it with fresh exports each month. + diff --git a/submissions/create-copilot-adoption-dashboard/SKILL.md b/submissions/create-copilot-adoption-dashboard/SKILL.md new file mode 100644 index 00000000..2ef9e9c8 --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/SKILL.md @@ -0,0 +1,166 @@ +--- +name: create-copilot-adoption-dashboard +description: | + Turns the four Microsoft admin center (MAC) Copilot activity exports into a + populated, single-file HTML adoption dashboard using the Copilot Adoption + Analysis framework (Pillars A–E and the KPI scorecard). Triggered by the + `create-copilot-adoption-dashboard` command. Use when the user asks to + "create a Copilot adoption dashboard", "populate the adoption dashboard + template", "build my MAC report dashboard", "generate a Copilot usage + dashboard", "turn my Copilot exports into a dashboard", or "make an adoption + at-a-glance report". It first explains the process, then collects a company + name (required) and logo (optional) plus the four CSV exports (Copilot usage, + Agents per-user, Agents per-agent, Copilot Chat), computes the real metrics, + and replaces every template placeholder with the customer's data. Do NOT use + for a plain HTML page or a one-off chart with no MAC data — use the html or + render-ui skill instead. Do NOT use to query a Power BI report — use the + powerbi skill instead. +cowork: + category: analysis + icon: DataBarVertical +--- + +## Overview + +This skill builds a **Microsoft 365 Copilot adoption dashboard** by feeding four +MAC report exports through the *Copilot Adoption Analysis* framework and +injecting the computed results into a bundled single-file HTML template. The +output is one self-contained `.html` file (no libraries, no network) branded +with the customer's name and logo, carrying only real data — every template +placeholder, sample number, and template label is replaced. Every KPI tile and +panel carries an `i` definition button, the segment donut, Top surfaces and +Agent usage share charts reveal the underlying **user counts on hover**, and a +**Recommended next steps** section below the charts turns the measured values +into the framework's enablement plays — split into KPI-triggered plays and +ongoing training/awareness focus. The framework logic +(pillars, thresholds, KPI formulas, and the data contract) lives in +[references/adoption-framework.md](references/adoption-framework.md); the +computation and injection are done by +[scripts/build_dashboard.py](scripts/build_dashboard.py). + +## When to Use + +- The user wants their four Copilot MAC exports turned into a populated dashboard. +- The user runs the `create-copilot-adoption-dashboard` command. +- The user asks to populate or brand the adoption dashboard template with real data. + +## When NOT to Use + +- A generic HTML page, report, or single chart with no MAC data — use the **html** or **render-ui** skill instead. +- Reading numbers from a Power BI report or semantic model — use the **powerbi** skill instead. +- Summarizing a meeting or email thread — use **meeting-intel** or the Outlook tools instead. +- Evaluating individual users' performance — decline; this skill reports aggregate adoption only. + +## Quick Start + +``` +User: /create-copilot-adoption-dashboard +1. Explain the process (below) and list the four reports needed. +2. Ask for company name (required) + logo (optional); find the 4 CSVs in input/. +3. Inspect each CSV's headers and map it to a report (--usage / --agents-user / + --agents-agent / --chat). +4. Run scripts/build_dashboard.py to compute the metrics and write the HTML to working/. +5. Review the KPI summary, publish to output/ with host-CopyArtifact, confirm with Glob. +``` + +## Core Instructions + +### Phase 1 — Explain the process (always do this first) + +Tell the user, in a few plain lines, how this works: + +> This builds your Copilot adoption dashboard from four exports you pull from the +> Microsoft 365 admin center Copilot reports: **(1) Copilot usage** (per licensed +> user), **(2) Agents – per user**, **(3) Agents – per agent**, and **(4) Copilot +> Chat** (includes unlicensed users). I'll compute license activation, the +> habitual-user and agent-adoption rates, user segments (Champion → Never), top +> app surfaces, per-agent usage share, and the unlicensed-Chat conversion funnel, +> then drop them into a branded, single-file dashboard. The reporting window is +> taken from the **Report Period** column in your export (the admin center +> default is 28 days), so the dashboard always states the period your data +> actually covers. I just need your **company name** (required), an optional +> **logo**, and the **four CSV files**. + +### Phase 2 — Collect inputs + +1. **Company name + logo:** if not already given, ask with `core-AskUserQuestion` + (company name is required; logo is optional — a PNG/JPG/SVG file). Do not ask + for anything a lookup can answer. +2. **The four CSVs:** find them with `Glob` on `input/**/*.csv`. If fewer than + four are present, tell the user which report types are missing and proceed + with the ones supplied (missing reports degrade to a pending panel — never + invented data). + +### Phase 3 — Map columns and compute + +1. **Inspect headers** of each CSV (read the first two lines) and match each file + to a report flag using the signatures in the reference: per-app **Loop/OneNote** + columns → `--usage`; **agents-used** column → `--agents-user`; an **agent-name** + row grain → `--agents-agent`; a **web-chat** surface / unlicensed users → `--chat`. +2. **Run the build** (from the skill directory) — write to `working/` first: + ``` + python scripts/build_dashboard.py \ + --company "" [--logo input/] \ + --usage input/.csv --agents-user input/.csv \ + --agents-agent input/.csv --chat input/.csv \ + --out working/_Copilot_Adoption_Dashboard.html + ``` + **Omit `--period`** — the window comes from the export's **Report Period** + column (MAC default 28 days). Pass `--period` only when the user explicitly + asks for a different window, and `--report-date` only to override the + export's Report Refresh Date. +3. **Read the JSON summary** the script prints. Check `periodDays` and + `periodSource` — confirm the window matches the export the user supplied + (e.g. `28` from the Report Period column) and mention it when you report back. + If `warnings` names an unmapped column, write a small `--col-map` JSON (see + the reference) and re-run — do not hand-edit numbers. Confirm + `template_reference_check` is `clean`. + +### Phase 4 — Review and deliver + +1. Show the user a short **table** of the computed KPIs (activation, habitual, + agent adoption, conversion), the **number of recommended next steps** the + dashboard raised, plus the report date, the **reporting window and + where it came from** (e.g. "28-day window, from the export's Report Period + column"), and which sources were used, for a quick sanity check before delivery. +2. Publish the finished file to `output/` with + `host-CopyArtifact(surface="output", source="working/.html", destination=".html")`, + then confirm it exists with `Glob output/**/*`. +3. Tell the user the dashboard is ready and note any pending panels (missing sources). + +## Output + +- **Primary deliverable:** one self-contained `.html` dashboard in `output/`, + named `_Copilot_Adoption_Dashboard.html`, opening directly in any browser. +- **Chat reply:** 3–5 lines — the KPI summary table, the report date/window, and + a note of any missing source. Keep internal paths and tool names out of it. + +## Guardrails + +- **Never fabricate** a metric: every KPI, segment, and share is computed by the + script from the exports. If a column can't be mapped, provide a `--col-map` and + re-run; never type a number in by hand. +- **Always inspect headers before computing** — tenants name columns differently; + confirm each file maps to the right report flag. +- **Never hard-code the reporting window.** It is read from the export's **Report + Period** column (MAC default 28 days) and shown on the dashboard badge and + footer; only pass `--period` when the user explicitly asks for a different + window. If the script warns that reports disagree or that no Report Period + column was found, tell the user which window was used and why. +- If a report is missing, leave its `sources` flag false so the panel renders as + **pending** — do not guess or copy sample values as a fallback. +- Thresholds (segments, lead tiers, KPI targets) come from the framework and are + tunable; if the user asks, adjust them in the reference, not by editing outputs. +- **Review before delivery:** show the KPI summary table and confirm the company + name spelling before publishing the final file; keep the four exports in + `working/` and deliver only the dashboard. +- **Recommendations are threshold-driven, never invented.** KPI-triggered cards + appear only when a measured value trips its framework threshold, and each + cites that number; the ongoing training/awareness cards are labelled as such + because they are not threshold-driven. Do not add, reorder or soften cards by + hand — if a threshold needs to change, change the template's `RECO_T` and say so. +- **Dormancy is not a recommendation trigger.** The framework treats it as a + trend needing two reporting periods; this is a single-period view, so the + Dormant segment is charted but never used to raise a play. +- This skill reports **aggregate** adoption only — never rank or evaluate + individual users. Redirect performance questions to the user's manager or HR. diff --git a/submissions/create-copilot-adoption-dashboard/assets/dashboard_template.html b/submissions/create-copilot-adoption-dashboard/assets/dashboard_template.html new file mode 100644 index 00000000..9642be86 --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/assets/dashboard_template.html @@ -0,0 +1,656 @@ + + + + + + +Copilot Adoption — Monitoring Dashboard (Single-period template) + + + + + +
+
+
+ +
+ Microsoft 365 Copilot · Adoption + Company name +
+
+
+
Report date: —
+
+
+ +
+
+
Monitoring dashboard
+

Copilot Adoption — at a glance

+
+ +
+
+
+ +
+ + + + diff --git a/submissions/create-copilot-adoption-dashboard/metadata.json b/submissions/create-copilot-adoption-dashboard/metadata.json new file mode 100644 index 00000000..36d0c2cc --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "Copilot Adoption Dashboard", + "description": "Using exported files from MAC usage reports, build html Copilot Adoption Dashboard with integrated framework that suggest next steps", + "platforms": ["Cowork"], + "tags": ["Copilot", "Adoption", "Data", "Dasbhoard", "KPIs", "Measure"], + "author": "Sead Borovina", + "authorUrl": "https://www.linkedin.com/in/seadb/", + "version": "1.0.0", + "createdAt": "2026-08-05", + "updatedAt": "2026-08-05" +} diff --git a/submissions/create-copilot-adoption-dashboard/references/adoption-framework.md b/submissions/create-copilot-adoption-dashboard/references/adoption-framework.md new file mode 100644 index 00000000..9b020213 --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/references/adoption-framework.md @@ -0,0 +1,191 @@ +# Copilot Adoption Analysis — framework logic + +The reference the build script and the agent follow when turning the four MAC +(Microsoft admin center) Copilot activity exports into a populated dashboard. +Source: *Copilot Adoption Analysis — A suggested analytical approach & reporting +framework* (S. Borovina, July 2026). Thresholds below are the framework's +defaults and are tunable per org. + +## The four reports (join key = user) + +Three are per-user tables joined on the user column; the fourth is per-agent. + +| # | Report (`--flag`) | Grain | Key columns the framework uses | Signature to recognise it | +|---|---|---|---|---| +| 1 | **Copilot usage** (`--usage`) | one licensed user per row | **Report Period**; **Report Refresh Date**; user key; prompts submitted; active days; overall last activity; per-app last-activity for Teams, Word, Excel, PowerPoint, Outlook, OneNote, Loop, M365 Copilot app, Edge | per-app Copilot columns incl. **Loop / OneNote**, plus prompts + active-days | +| 2 | **Agents — per user** (`--agents-user`) | one licensed user per row | user key; number of agents used; agent responses received; last activity | has **agents-used** / **agent-responses** columns | +| 3 | **Agents — per agent** (`--agents-agent`) | one agent per row | agent name; creator type; active users (licensed / unlicensed); responses sent; last activity | has an **agent-name** column + **active-users** / **creator** | +| 4 | **Copilot Chat** (`--chat`) | one user per row, **includes unlicensed** | user key; prompts submitted; active days; last activity; per-surface last-activity incl. **web chat** | has a **web-chat** surface column; the only report with unlicensed users | + +The strategic hinge: Copilot Chat reaches users with no paid license. Active Chat +users who are **not** already active in licensed in-app Copilot are the +conversion pipeline (Pillar E). + +## Reporting window (read from the data, never assumed) + +MAC exports record the window they were generated for in a **Report Period** +column — the admin center default is **28 days** (the picker also offers 7, 30 +and 90). The window drives the dashboard badge and footer ("*N*-day window") and +the per-app activity recency test, so it is resolved from the export itself: + +1. An explicit `--period` flag, if the user asked for a specific window (a + mismatch with the data is reported as a warning). +2. The **Report Period** column of the Copilot usage export. +3. The **Report Period** column of the Copilot Chat export. +4. Failing all of those, the **28-day MAC default**, with a warning. + +Values may be plain (`28`) or worded (`Last 28 days`); the first integer in the +range 1–365 is taken, and the most common value wins if rows disagree. A window +outside 7/28/30/90, mixed values within one report, or two reports disagreeing +each raise a warning so the export can be checked. + +The **report date** resolves the same way: `--report-date`, else the export's +**Report Refresh Date**, else today (flagged as an assumption). + +## The five pillars (thresholds; tuned to the detected window) + +**Pillar A — Adoption segmentation** (from Copilot usage). Each licensed user is +placed in exactly one segment using a waterfall (first match wins), where +`apps` = number of per-app columns with recent activity: + +1. **Never activated** — no prompts, no active days, no activity at all. +2. **Dormant / at-risk** — had activity, but last activity **≥ 28 days** ago. +3. **Champion / power user** — active days **≥ 12** AND (prompts **≥ 60** OR apps **≥ 4**). +4. **Habitual** — active days **≥ 5** (i.e. 5–11 and not a Champion). +5. **Casual / occasional** — any remaining user with 1–4 active days. + +**Pillar B — Surface breadth** (from Copilot usage). For each app, share of +licensed users with recent activity in that app = users-active-in-app ÷ assigned. +Dashboard shows the **top 10** surfaces, each carrying its user count for the +hover tooltip. Multi-surface (≥ 3 apps) users get the most value; low-share apps +(including any that come back at 0%) are the targeted-enablement candidates. + +**Pillar C — Agent adoption** (from Agents per-user). Feeds the KPI only: agent +adoption rate = users with ≥ 1 agent ÷ licensed users. (Full sub-segments — +Power user ≥ 3 agents & ≥ 50 responses, Regular 1–2 & ≥ 20, Explorer 1–2 & < 20, +Non-adopter 0 — are documented for future depth but not rendered.) + +**Pillar D — Agent usage share** (from Agents per-agent). For each agent, +active-user share = that agent's active users ÷ the distinct agent-active user +base (users with ≥ 1 agent, from Pillar C). Users may use several agents, so +shares need not sum to 100. Dashboard shows the **top 10** agents, each carrying +its user count for the hover tooltip. + +**Pillar E — Conversion pipeline** (from Copilot Chat). Rank unlicensed, +Chat-active users by intent (Chat prompts × active days × recency), first match +wins: + +- **Hot** — prompts **≥ 30** AND active days **≥ 8** AND recent (last activity ≤ 7 days). +- **Warm** — prompts **≥ 10** AND active days **≥ 3** (and not Hot). +- **Cool** — prompts **≥ 1** AND active days **≥ 1** (and not Warm/Hot). + +Funnel = *Unlicensed active* (any Chat-active unlicensed user) → *Warm+* +(Hot + Warm) → *Hot*. + +## KPI scorecard (the four dashboard tiles) + +| KPI | Formula | Framework target | +|---|---|---| +| **License activation** | active licensed users ÷ licenses assigned | ≥ 80% | +| **Habitual-user rate** | users active on ≥ 5 days ÷ licensed users | ≥ 40% | +| **Agent adoption** | users with ≥ 1 agent ÷ licensed users | ≥ 30% | +| **Conversion-ready leads** | count of Hot + Warm unlicensed Chat leads | grow the pipeline | + +"Assigned licenses" = every row in the Copilot usage export (each row is an +assigned licensed user). "Active" = a user who is not in the *Never activated* +segment. + +## Dashboard data contract (what the script injects) + +The template exposes one JavaScript object between `@DASHBOARD_DATA:BEGIN` and +`@DASHBOARD_DATA:END`. The script replaces that block (and the template header +comment, title, and footer note) so the output carries only real data: + +``` +company string customer / org name +logo string inline "data:image/…;base64,…" URI, or "" for the default mark +reportDate string ISO "YYYY-MM-DD" (--report-date, else Report Refresh Date, else today) +periodDays number MAC window read from the Report Period column: 7 | 28 | 30 | 90 + (--period overrides; 28 assumed only if the column is absent) +sources { usage, agentsUser, agentsAgent, chat } booleans — false → related panel shows "pending" +kpis { activation:float, activationSub:"1,240 of 1,500", habitual:int, agentAdoption:int, conversion:int } +segments[] { name, value, count } five rows Champion→Never; value = % share + (sum = 100), count = users (shown on hover) +surfaces[] { name, pct, users } top-10 apps by % of users active +agentShare[] { name, pct, users } top-10 agents by active-user share +funnel[] { name, value } Unlicensed active, Warm+, Hot (counts) +``` + +If a report is not supplied, its `sources` flag is set `false`; the template dims +and marks the related panel as pending, and the footer lists the missing source. + +## Recommended next steps (the framework's Action plays) + +Below the charts the dashboard renders enablement plays from the framework's +action matrix. A card appears **only when a measured value trips its threshold**, +so the list stays short and every item cites the number that triggered it. This +is evaluated in the template from `DASHBOARD_DATA` (`buildRecos()` / `RECO_T`), +so the section stays correct if the data object is edited. + +**Group 1 - triggered by your KPI values.** Shown only when a measured value +trips its scorecard threshold; each card cites the number that triggered it. + +| Trigger (measured) | Threshold | Play | +|---|---|---| +| Low activation **or** many never-activated | activation < 80% or Never >= 10% | Onboarding campaign, first-prompt nudges, reclaim & reassign idle licences | +| Low depth | habitual < 40% | Role-based use cases, champions programme, lunch-and-learns | +| Narrow surface breadth | multi-surface < 50% | App-specific enablement, one dark surface at a time | +| Low agent adoption | agent adoption < 30% | Publish role-relevant agents, showcase high-value ones, target power users | +| Strong Chat pipeline (upside) | Hot + Warm > 0 | Business case for expansion, pilot-convert Hot leads, measure uplift | + +**Group 2 - ongoing recommendations.** NOT threshold-driven: these rank the +surfaces and agents to point steady-state effort, so they are labelled +separately and shown **even when every KPI passes**. + +| Focus | Selection | Play | +|---|---|---| +| Training focus | top 3 surfaces / agents by share | Deepen where adoption already exists; advanced role-specific sessions | +| Awareness focus | surfaces < 25%, agents < 10% | Promote the quiet surfaces one at a time; retire-or-relaunch the agent tail | + +Every threshold above comes from the framework scorecard. **Dormancy is +deliberately not a trigger** - the framework treats it as a trend requiring two +reporting periods, and this dashboard is a single-period view, so flagging it +from one export would overstate what the data supports. The Dormant *segment* +still appears in the Pillar A donut, where "last activity >= 28 days ago" is +computable from a single export. + +When no Group 1 card is triggered the section leads with *"You're doing great - +keep it up!"*; if reports were missing, that banner says so rather than implying +the untested pillars passed. Group 2 is shown regardless. + +## Dashboard interactivity + +Everything is pure CSS/SVG — no libraries, no scripts beyond the inline one. + +- **Definitions:** all four KPI tiles *and* all four panels carry an `i` button + (hover or keyboard focus) showing the metric's definition, how it is + calculated, and the framework target. Panel definitions live in `PANEL_DEFS` + in the template and mirror the pillar rules above; pending panels keep their + button so a missing source is still self-explanatory. +- **Hover values:** the donut arcs, the donut legend rows, and every bar in + Top surfaces and Agent usage share show `name: share · N users` on hover, so + the underlying head-count is always one mouse-over away from the percentage. + The count comes from the `count` / `users` field; if a payload omits it the + tooltip degrades to the share alone. + +## Column resolution + +Header names vary between tenants and export versions, so the script matches +columns by normalised keyword (lower-cased, punctuation/space-stripped), not by +exact string. When a needed column cannot be matched it stops and lists the +available headers so the agent can supply a `--col-map` JSON override, e.g.: + +```json +{ + "usage": { "user": "UPN", "prompts": "Total prompts", "active_days": "Active days" }, + "chat": { "user": "UPN", "prompts": "Chat prompts", "active_days": "Active days" } +} +``` + +Never fabricate a metric a column cannot support — map the column or leave the +related source out and let the panel render as pending. diff --git a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py new file mode 100644 index 00000000..077b5e40 --- /dev/null +++ b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py @@ -0,0 +1,672 @@ +#!/usr/bin/env python3 +"""Build a populated Copilot Adoption dashboard from four MAC report exports. + +Reads the four Microsoft admin center (MAC) Copilot activity exports, computes +the five-pillar adoption metrics and KPI scorecard defined in +references/adoption-framework.md, and injects the results into the bundled +single-file HTML template (assets/dashboard_template.html), replacing every +template placeholder so the output carries only the customer's real data. + +Standard library only. No network access. Reads CSV files, writes one HTML file +and prints a JSON run summary (mapping, KPIs, warnings) to stdout. + +Typical use: + python build_dashboard.py \ + --company "Contoso" --logo /path/logo.png \ + --usage usage.csv --agents-user agents_user.csv \ + --agents-agent agents_agent.csv --chat chat.csv \ + --period 30 --out working/Contoso_Copilot_Adoption_Dashboard.html + +If a column cannot be matched, pass --col-map map.json (see the reference). +""" +from __future__ import annotations + +import argparse +import base64 +import csv +import json +import re +import sys +from datetime import datetime, date +from pathlib import Path + +# --------------------------------------------------------------------------- # +# Column-concept keyword aliases. Headers are normalised (lower-cased, all +# non-alphanumerics removed) before matching, so "User Principal Name", +# "user_principal_name" and "UPN" all reduce to comparable tokens. +# --------------------------------------------------------------------------- # + +USER_KEYS = ["userprincipalname", "userprincipal", "upn", "useremail", + "emailaddress", "email", "userid", "user", "displayname", "name"] + +CONCEPTS = { + "prompts": ["promptssubmitted", "totalprompts", "promptcount", "prompts", + "copilotactions", "actionscount"], + "active_days": ["activedays", "daysactive", "numberofactivedays", "activedaycount"], + "last_activity": ["lastactivitydate", "lastactivity", "lastactive"], + "agents_used": ["numberofagentsused", "agentsused", "numberofagents", + "distinctagents", "agentcount", "agentsaccessed"], + "agent_responses": ["agentresponsesreceived", "agentresponses", + "responsesreceived", "responses"], + "agent_name": ["agentname", "agentdisplayname", "agent", "declarativeagentname"], + "active_users": ["activeusers", "totalactiveusers", "activeusercount"], + "active_users_licensed": ["activeuserslicensed", "licensedactiveusers"], + "active_users_unlicensed": ["activeusersunlicensed", "unlicensedactiveusers"], + "creator_type": ["creatortype", "creator"], + "chat_prompts": ["promptssubmitted", "chatprompts", "totalprompts", + "promptcount", "prompts"], + # The MAC exports carry the window they were generated for (default 28 days) + # and the date the report data was refreshed. + "report_period": ["reportperiod", "reportingperiod", "reportperioddays", + "reportwindow", "windowdays", "period"], + "report_refresh": ["reportrefreshdate", "reportrefresh", "refreshdate", + "datagenerateddate", "reportdate"], +} + +# Windows the MAC report picker offers; anything else is accepted but flagged. +STANDARD_PERIODS = {7, 28, 30, 90} +DEFAULT_PERIOD = 28 # MAC default when no Report Period column is present + +# App display name -> tokens that identify its per-app last-activity column. +APPS = [ + ("Teams", ["teams"]), + ("Outlook", ["outlook"]), + ("Word", ["word"]), + ("Excel", ["excel"]), + ("PowerPoint", ["powerpoint", "ppt"]), + ("OneNote", ["onenote"]), + ("Loop", ["loop"]), + ("App", ["copilotapp", "m365copilotapp", "m365app", "copilotmobile", "mobileapp"]), + ("Edge", ["edge"]), + ("Web chat", ["webchat", "chatweb", "web"]), +] +ACTIVITY_TOKENS = ["lastactivity", "activitydate", "lastactive", "activity"] + +SEGMENT_ORDER = ["Champion", "Habitual", "Casual", "Dormant", "Never"] +DATE_FORMATS = ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", + "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d", "%b %d, %Y", "%d %b %Y", + "%m/%d/%Y %H:%M", "%d-%b-%Y", "%d.%m.%Y"] +TOP_N = 10 # surfaces + agents shown in the ranked bar panels +TEMPLATE_TOKENS = ["single-period template", "populated by a", "cowork skill", + "illustrative", "placeholder", "@dashboard_data", + "company name"] + + +def norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", (s or "").lower()) + + +def to_int(v) -> int: + if v is None: + return 0 + s = str(v).strip().replace(",", "") + if not s or s in ("-", "n/a", "na", "none", "null"): + return 0 + m = re.search(r"-?\d+", s) + return int(m.group(0)) if m else 0 + + +def parse_date(v): + if v is None: + return None + s = str(v).strip() + if not s or s in ("-", "n/a", "na", "none", "null"): + return None + try: + return datetime.fromisoformat(s.replace("Z", "").strip()).date() + except ValueError: + pass + for fmt in DATE_FORMATS: + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + return None + + +_READ_CACHE: dict = {} + + +def sniff_read(path: str): + """Read a CSV robustly (BOM + delimiter sniffing). Returns (headers, rows). + + Results are cached per path so the period pre-scan and the pillar + computation each read a large export only once. + """ + if path in _READ_CACHE: + return _READ_CACHE[path] + headers, dicts = _sniff_read_uncached(path) + _READ_CACHE[path] = (headers, dicts) + return headers, dicts + + +def _sniff_read_uncached(path: str): + raw = Path(path).read_text(encoding="utf-8-sig", errors="replace") + sample = raw[:8192] + delim = "," + try: + delim = csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter + except csv.Error: + for cand in (";", "\t", "|"): + if sample.count(cand) > sample.count(","): + delim = cand + reader = csv.reader(raw.splitlines(), delimiter=delim) + rows = [r for r in reader if any((c or "").strip() for c in r)] + if not rows: + return [], [] + headers = rows[0] + dicts = [] + for r in rows[1:]: + r = list(r) + [""] * (len(headers) - len(r)) + dicts.append({headers[i]: r[i] for i in range(len(headers))}) + return headers, dicts + + +class Resolver: + """Resolves concept names to actual header strings for one report.""" + + def __init__(self, headers, overrides=None): + self.headers = headers + self.norm_map = {norm(h): h for h in headers} + self.overrides = {k: v for k, v in (overrides or {}).items()} + self.resolved = {} + + def find(self, concept, aliases): + if concept in self.overrides: + self.resolved[concept] = self.overrides[concept] + return self.overrides[concept] + for a in aliases: + na = norm(a) + if na in self.norm_map: # exact normalised hit + self.resolved[concept] = self.norm_map[na] + return self.norm_map[na] + for a in aliases: # substring fallback + na = norm(a) + for nh, orig in self.norm_map.items(): + if na and na in nh: + self.resolved[concept] = orig + return orig + return None + + def user(self): + return self.find("user", USER_KEYS) + + def app_columns(self): + """Map app display name -> header for its per-app activity column.""" + found = {} + for header in self.headers: + nh = norm(header) + if not any(t in nh for t in ACTIVITY_TOKENS): + continue + for disp, toks in APPS: + if disp in found: + continue + if any(t in nh for t in toks): + found[disp] = header + break + return found + + +def detect_period(rows, col): + """Most-common integer day-count in a Report Period column. + + Values may read "28" or "Last 28 days"; the first integer is taken and only + plausible day counts (1-365) are considered, so a column holding dates or + labels simply yields no result instead of a bogus window. + """ + tally: dict = {} + for row in rows: + v = to_int(row.get(col)) + if 1 <= v <= 365: + tally[v] = tally.get(v, 0) + 1 + if not tally: + return None, {} + best = max(tally.items(), key=lambda kv: (kv[1], -kv[0]))[0] + return best, tally + + +def prescan(path, overrides, label, warn): + """Read an export's Report Period / Report Refresh Date before computing. + + The reporting window drives both the dashboard badge and the per-app + activity recency test, so it is resolved from the data rather than assumed. + """ + headers, rows = sniff_read(path) + r = Resolver(headers, overrides) + c_period = r.find("report_period", CONCEPTS["report_period"]) + c_refresh = r.find("report_refresh", CONCEPTS["report_refresh"]) + period, tally = detect_period(rows, c_period) if c_period else (None, {}) + if c_period and period is None: + warn.append(f"{label}: '{c_period}' held no usable day count; " + "the window was taken from another source.") + if len(tally) > 1: + mixed = ", ".join(f"{k}d x{v}" for k, v in sorted(tally.items())) + warn.append(f"{label}: mixed Report Period values ({mixed}); " + f"used the most common ({period} days).") + if period is not None and period not in STANDARD_PERIODS: + warn.append(f"{label}: Report Period is {period} days, which is not a " + "standard MAC window (7/28/30/90) — verify the export.") + refresh = None + if c_refresh: + dates = [d for d in (parse_date(row.get(c_refresh)) for row in rows) if d] + if dates: + refresh = max(dates) + return {"period": period, "period_column": c_period, + "refresh_date": refresh, "refresh_column": c_refresh} + + +def largest_remainder(counts, total): + """Round a list of counts to integer percentages that sum to 100.""" + if total <= 0: + return [0] * len(counts) + raw = [c / total * 100 for c in counts] + floors = [int(x) for x in raw] + remainder = 100 - sum(floors) + order = sorted(range(len(raw)), key=lambda i: raw[i] - floors[i], reverse=True) + for i in range(remainder): + floors[order[i % len(order)]] += 1 + return floors + + +# --------------------------------------------------------------------------- # +# Pillar computations +# --------------------------------------------------------------------------- # + +def compute_usage(path, report_date, period, overrides, warn): + headers, rows = sniff_read(path) + r = Resolver(headers, overrides) + ukey = r.user() + c_prompts = r.find("prompts", CONCEPTS["prompts"]) + c_days = r.find("active_days", CONCEPTS["active_days"]) + c_last = r.find("last_activity", CONCEPTS["last_activity"]) + app_cols = r.app_columns() + if not c_prompts and not c_days and not app_cols: + raise ValueError( + "Copilot usage: could not find prompts, active-days or per-app " + "activity columns. Headers: " + " | ".join(headers)) + + assigned = len(rows) + seg_counts = {s: 0 for s in SEGMENT_ORDER} + app_active = {a: 0 for a in app_cols} + active = habitual = multi_surface = 0 + users = set() + + for row in rows: + if ukey: + users.add(norm(row.get(ukey, ""))) + prompts = to_int(row.get(c_prompts)) if c_prompts else 0 + days = to_int(row.get(c_days)) if c_days else 0 + apps_active = 0 + latest_app = None + for disp, col in app_cols.items(): + d = parse_date(row.get(col)) + cell = (row.get(col) or "").strip() + is_active = bool(cell) and (d is None or (report_date - d).days <= period) + if is_active: + apps_active += 1 + app_active[disp] += 1 + if d and (latest_app is None or d > latest_app): + latest_app = d + last = parse_date(row.get(c_last)) if c_last else None + if last is None: + last = latest_app + has_activity = prompts > 0 or days > 0 or apps_active > 0 or last is not None + + if not has_activity: + seg = "Never" + elif last is not None and (report_date - last).days >= 28: + seg = "Dormant" + elif days >= 12 and (prompts >= 60 or apps_active >= 4): + seg = "Champion" + elif days >= 5: + seg = "Habitual" + else: + seg = "Casual" + seg_counts[seg] += 1 + if has_activity: + active += 1 + if days >= 5: + habitual += 1 + if apps_active >= 3: # framework "multi-surface" breadth KPI + multi_surface += 1 + + counts = [seg_counts[s] for s in SEGMENT_ORDER] + seg_pct = largest_remainder(counts, assigned) + # count rides alongside the percentage so the donut can show users on hover. + segments = [{"name": s, "value": seg_pct[i], "count": seg_counts[s]} + for i, s in enumerate(SEGMENT_ORDER)] + + surfaces = sorted( + ({"name": a, "pct": round(app_active[a] / assigned * 100), + "users": app_active[a]} for a in app_active), + key=lambda x: (x["pct"], x["users"]), reverse=True)[:TOP_N] if assigned else [] + + if not c_days: + warn.append("Copilot usage: no active-days column — habitual rate and " + "Champion/Habitual split are approximate.") + + return { + "assigned": assigned, "active": active, "users": users, + "activation": round(active / assigned * 100, 1) if assigned else 0.0, + "activationSub": f"{active:,} of {assigned:,}", + "habitual": round(habitual / assigned * 100) if assigned else 0, + "multiSurface": round(multi_surface / assigned * 100) if assigned else 0, + "segments": segments, "surfaces": surfaces, + "mapping": r.resolved | {"apps": app_cols}, + } + + +def compute_agents_user(path, overrides, warn): + headers, rows = sniff_read(path) + r = Resolver(headers, overrides) + c_agents = r.find("agents_used", CONCEPTS["agents_used"]) + if not c_agents: + raise ValueError("Agents (per user): no agents-used column. Headers: " + + " | ".join(headers)) + adopters = sum(1 for row in rows if to_int(row.get(c_agents)) >= 1) + return {"agent_users": adopters, "rows": len(rows), "mapping": r.resolved} + + +def compute_agents_agent(path, base_users, overrides, warn): + headers, rows = sniff_read(path) + r = Resolver(headers, overrides) + c_name = r.find("agent_name", CONCEPTS["agent_name"]) + c_users = r.find("active_users", CONCEPTS["active_users"]) + c_lic = r.find("active_users_licensed", CONCEPTS["active_users_licensed"]) + c_unlic = r.find("active_users_unlicensed", CONCEPTS["active_users_unlicensed"]) + if not c_name: + raise ValueError("Agents (per agent): no agent-name column. Headers: " + + " | ".join(headers)) + + def users_for(row): + if c_users: + return to_int(row.get(c_users)) + return to_int(row.get(c_lic)) + to_int(row.get(c_unlic)) + + agents = [(str(row.get(c_name) or "").strip() or "Unnamed agent", users_for(row)) + for row in rows] + agents = [a for a in agents if a[0]] + total_instances = sum(u for _, u in agents) + base = base_users if base_users and base_users > 0 else total_instances + if not (base_users and base_users > 0): + warn.append("Agents (per agent): no distinct agent-user base from the " + "per-user report — usage share is relative to total agent " + "instances instead of distinct users.") + # users rides alongside the share so each bar can show the count on hover. + share = sorted( + ({"name": n, "pct": min(100, round(u / base * 100)) if base else 0, "users": u} + for n, u in agents), + key=lambda x: (x["pct"], x["users"]), reverse=True)[:TOP_N] + return {"agentShare": share, "mapping": r.resolved} + + +def compute_chat(path, report_date, licensed_users, overrides, warn): + headers, rows = sniff_read(path) + r = Resolver(headers, overrides) + ukey = r.user() + c_prompts = r.find("chat_prompts", CONCEPTS["chat_prompts"]) + c_days = r.find("active_days", CONCEPTS["active_days"]) + c_last = r.find("last_activity", CONCEPTS["last_activity"]) + if not c_prompts and not c_days: + raise ValueError("Copilot Chat: no prompts or active-days column. " + "Headers: " + " | ".join(headers)) + if not licensed_users: + warn.append("Copilot Chat: no Copilot usage report to exclude licensed " + "users — every Chat-active user is treated as unlicensed.") + + unlicensed_active = warm = hot = cool = 0 + for row in rows: + key = norm(row.get(ukey, "")) if ukey else "" + if licensed_users and key and key in licensed_users: + continue # already a licensed in-app user + p = to_int(row.get(c_prompts)) if c_prompts else 0 + d = to_int(row.get(c_days)) if c_days else 0 + if p < 1 and d < 1: + continue + unlicensed_active += 1 + last = parse_date(row.get(c_last)) if c_last else None + recent = last is not None and (report_date - last).days <= 7 + if p >= 30 and d >= 8 and recent: + hot += 1 + elif p >= 10 and d >= 3: + warm += 1 + elif p >= 1 and d >= 1: + cool += 1 + + funnel = [ + {"name": "Unlicensed active", "value": unlicensed_active}, + {"name": "Warm+", "value": warm + hot}, + {"name": "Hot", "value": hot}, + ] + return {"funnel": funnel, "conversion": warm + hot, "mapping": r.resolved} + + +# --------------------------------------------------------------------------- # +# Template injection +# --------------------------------------------------------------------------- # + +def html_escape(s: str) -> str: + return (str(s).replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def encode_logo(path, warn): + if not path: + return "" + p = Path(path) + if not p.exists(): + warn.append(f"Logo file not found ({path}); using the default mark.") + return "" + mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp"}.get( + p.suffix.lower(), "image/png") + data = base64.b64encode(p.read_bytes()).decode("ascii") + return f"data:{mime};base64,{data}" + + +def inject(template_text, data, company): + txt = template_text + # 1. Remove the template's top explanatory HTML comment. + txt = re.sub(r"", + "", + txt, count=1) + # 2. Company-specific and header text node (the JS also sets it at + # runtime; replacing the static node leaves no template text in source). + txt = re.sub(r"<title>[\s\S]*?", + f"{html_escape(company)} — Microsoft 365 Copilot Adoption", + txt, count=1) + txt = txt.replace('id="companyName">Company name', + f'id="companyName">{html_escape(company)}', 1) + # 3. Replace the DASHBOARD_DATA block (field-guide comment + object). + payload = json.dumps(data, indent=2, ensure_ascii=False) + block = ("/* Adoption metrics computed from the four Microsoft admin center " + "report exports. */\nconst DASHBOARD_DATA = " + payload + ";") + txt = re.sub(r"/\*\s*@DASHBOARD_DATA:BEGIN[\s\S]*?@DASHBOARD_DATA:END[\s\S]*?\*/", + lambda _m: block, txt, count=1) + # 4. Replace the footer's template note with a data-source line. + txt = txt.replace( + " Data, company name and logo are supplied by the Cowork skill.", + " Source: Microsoft 365 admin center Copilot activity exports.") + return txt + + +def check_no_template_refs(html): + low = html.lower() + hits = [] + for tok in TEMPLATE_TOKENS: + # Ignore the JS fallback literal "company name" inside a || expression. + if tok == "company name" and '|| "company name"' in low: + continue + if tok in low: + hits.append(tok) + return hits + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--company", required=True) + ap.add_argument("--logo", default="") + ap.add_argument("--usage") + ap.add_argument("--agents-user", dest="agents_user") + ap.add_argument("--agents-agent", dest="agents_agent") + ap.add_argument("--chat") + # Omit --period to use the window recorded in the exports' Report Period + # column (MAC default 28 days); pass it only to force a different window. + ap.add_argument("--period", type=int, default=None) + ap.add_argument("--report-date", dest="report_date", default="") + ap.add_argument("--col-map", dest="col_map", default="") + ap.add_argument("--template", default="") + ap.add_argument("--out", required=True) + args = ap.parse_args() + + here = Path(__file__).resolve().parent + template_path = Path(args.template) if args.template else here.parent / "assets" / "dashboard_template.html" + if not template_path.exists(): + print(json.dumps({"ok": False, "error": f"Template not found: {template_path}"})) + sys.exit(1) + + col_map = {} + if args.col_map: + col_map = json.loads(Path(args.col_map).read_text(encoding="utf-8")) + + warn = [] + + # ---- Resolve the reporting window and report date from the exports ---- + # Precedence: explicit flag > Copilot usage export > Copilot Chat export > + # MAC default. The window feeds the dashboard badge/footer and the per-app + # activity recency test, so it must reflect the data, not an assumption. + scans = {} + if args.usage: + scans["Copilot usage"] = prescan(args.usage, col_map.get("usage"), "Copilot usage", warn) + if args.chat: + scans["Copilot Chat"] = prescan(args.chat, col_map.get("chat"), "Copilot Chat", warn) + + detected_period = detected_from = None + for label, sc in scans.items(): + if sc["period"] is not None: + detected_period, detected_from = sc["period"], label + break + periods_seen = {lbl: sc["period"] for lbl, sc in scans.items() if sc["period"] is not None} + if len(set(periods_seen.values())) > 1: + warn.append("Reports disagree on the window (" + + "; ".join(f"{k}: {v}d" for k, v in periods_seen.items()) + + f") — used {detected_period} days from {detected_from}.") + + if args.period is not None: + period = args.period + period_source = "--period flag" + if detected_period is not None and detected_period != period: + warn.append(f"--period {period} overrides the {detected_period}-day " + f"window recorded in the {detected_from} export.") + elif detected_period is not None: + period = detected_period + period_source = f"Report Period column ({detected_from})" + else: + period = DEFAULT_PERIOD + period_source = f"default ({DEFAULT_PERIOD}-day MAC window)" + if scans: + warn.append("No Report Period column found in the exports; assumed " + f"the {DEFAULT_PERIOD}-day MAC default. Pass --period to set it.") + + report_date = parse_date(args.report_date) if args.report_date else None + if args.report_date and report_date is None: + warn.append(f"Could not parse --report-date '{args.report_date}'; " + "using the export's refresh date or today.") + date_source = "--report-date flag" if report_date else None + if report_date is None: + for label, sc in scans.items(): + if sc["refresh_date"]: + report_date, date_source = sc["refresh_date"], f"Report Refresh Date ({label})" + break + if report_date is None: + report_date, date_source = date.today(), "today (no refresh date in the exports)" + + sources = {"usage": False, "agentsUser": False, "agentsAgent": False, "chat": False} + kpis = {"activation": 0.0, "activationSub": "— source not provided", + "habitual": 0, "multiSurface": 0, "agentAdoption": 0, "conversion": 0} + segments = [{"name": s, "value": 0} for s in SEGMENT_ORDER] + surfaces, agent_share, funnel = [], [], [] + mapping = {} + licensed_users = set() + agent_users = 0 + + if args.usage: + u = compute_usage(args.usage, report_date, period, col_map.get("usage"), warn) + sources["usage"] = True + kpis["activation"] = u["activation"] + kpis["activationSub"] = u["activationSub"] + kpis["habitual"] = u["habitual"] + kpis["multiSurface"] = u["multiSurface"] + segments = u["segments"] + surfaces = u["surfaces"] + licensed_users = u["users"] + mapping["usage"] = u["mapping"] + else: + warn.append("No Copilot usage report — activation, habitual, segments and " + "surfaces are unavailable; those tiles/panels show as pending.") + + if args.agents_user: + au = compute_agents_user(args.agents_user, col_map.get("agents_user"), warn) + sources["agentsUser"] = True + agent_users = au["agent_users"] + base = u["assigned"] if args.usage else au["rows"] + kpis["agentAdoption"] = round(agent_users / base * 100) if base else 0 + mapping["agents_user"] = au["mapping"] + + if args.agents_agent: + aa = compute_agents_agent(args.agents_agent, agent_users, col_map.get("agents_agent"), warn) + sources["agentsAgent"] = True + agent_share = aa["agentShare"] + mapping["agents_agent"] = aa["mapping"] + + if args.chat: + ch = compute_chat(args.chat, report_date, licensed_users, col_map.get("chat"), warn) + sources["chat"] = True + funnel = ch["funnel"] + kpis["conversion"] = ch["conversion"] + mapping["chat"] = ch["mapping"] + + data = { + "company": args.company, + "logo": encode_logo(args.logo, warn), + "reportDate": report_date.isoformat(), + "periodDays": period, + "sources": sources, + "kpis": kpis, + "segments": segments, + "surfaces": surfaces, + "agentShare": agent_share, + "funnel": funnel, + } + + template_text = template_path.read_text(encoding="utf-8") + html = inject(template_text, data, args.company) + leftover = check_no_template_refs(html) + if leftover: + warn.append("Template references still present after injection: " + + ", ".join(sorted(set(leftover)))) + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + + summary = { + "ok": True, "output": str(out_path), "company": args.company, + "reportDate": data["reportDate"], "periodDays": period, + "periodSource": period_source, "reportDateSource": date_source, + "sources": sources, "kpis": kpis, + "segments": segments, "surfaces": surfaces, + "agentShare": agent_share, "funnel": funnel, + "column_mapping": mapping, "warnings": warn, + "template_reference_check": "clean" if not leftover else "leftover:" + ",".join(leftover), + } + print(json.dumps(summary, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() From ce20f1b07d68afa7e82da9e0489a8342969a56ec Mon Sep 17 00:00:00 2001 From: chancecontra <79384233+chancecontra@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:39:19 +0200 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/create-copilot-adoption-dashboard/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/create-copilot-adoption-dashboard/metadata.json b/submissions/create-copilot-adoption-dashboard/metadata.json index 36d0c2cc..b40d66b0 100644 --- a/submissions/create-copilot-adoption-dashboard/metadata.json +++ b/submissions/create-copilot-adoption-dashboard/metadata.json @@ -2,7 +2,7 @@ "name": "Copilot Adoption Dashboard", "description": "Using exported files from MAC usage reports, build html Copilot Adoption Dashboard with integrated framework that suggest next steps", "platforms": ["Cowork"], - "tags": ["Copilot", "Adoption", "Data", "Dasbhoard", "KPIs", "Measure"], + "tags": ["Copilot", "Adoption", "Data", "Dashboard", "KPIs", "Measure"], "author": "Sead Borovina", "authorUrl": "https://www.linkedin.com/in/seadb/", "version": "1.0.0", From 8ab59503f6ae0fba626b98db736cb6a7c9e2c2ee Mon Sep 17 00:00:00 2001 From: chancecontra <79384233+chancecontra@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:44:25 +0200 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/build_dashboard.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py index 077b5e40..6161dd99 100644 --- a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py +++ b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py @@ -457,8 +457,11 @@ def encode_logo(path, warn): if not p.exists(): warn.append(f"Logo file not found ({path}); using the default mark.") return "" + if p.suffix.lower() == ".svg": + warn.append("SVG logos are not supported because they can contain active content; use PNG/JPG/GIF/WebP instead.") + return "" mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp"}.get( + ".gif": "image/gif", ".webp": "image/webp"}.get( p.suffix.lower(), "image/png") data = base64.b64encode(p.read_bytes()).decode("ascii") return f"data:{mime};base64,{data}" From e60bf079fc2485f70a9295a440313a3a2e5c6e5d Mon Sep 17 00:00:00 2001 From: chancecontra <79384233+chancecontra@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:48:29 +0200 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/build_dashboard.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py index 6161dd99..a4c249be 100644 --- a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py +++ b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py @@ -485,9 +485,12 @@ def inject(template_text, data, company): payload = json.dumps(data, indent=2, ensure_ascii=False) block = ("/* Adoption metrics computed from the four Microsoft admin center " "report exports. */\nconst DASHBOARD_DATA = " + payload + ";") - txt = re.sub(r"/\*\s*@DASHBOARD_DATA:BEGIN[\s\S]*?@DASHBOARD_DATA:END[\s\S]*?\*/", - lambda _m: block, txt, count=1) - # 4. Replace the footer's template note with a data-source line. + pattern = (r"/\*\s*@DASHBOARD_DATA:BEGIN[\s\S]*?\*/\s*" + r"const\s+DASHBOARD_DATA\s*=\s*{[\s\S]*?};\s*" + r"/\*\s*@DASHBOARD_DATA:END[\s\S]*?\*/") + txt, n = re.subn(pattern, lambda _m: block, txt, count=1) + if n == 0: + raise ValueError("Could not locate @DASHBOARD_DATA markers in the template; template format may have changed.") txt = txt.replace( " Data, company name and logo are supplied by the Cowork skill.", " Source: Microsoft 365 admin center Copilot activity exports.") From 3b3d7b29cb4cc3be228a34a3d0c7643ac78e4d7d Mon Sep 17 00:00:00 2001 From: chancecontra <79384233+chancecontra@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:54:04 +0200 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/build_dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py index a4c249be..6511990d 100644 --- a/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py +++ b/submissions/create-copilot-adoption-dashboard/scripts/build_dashboard.py @@ -482,7 +482,7 @@ def inject(template_text, data, company): txt = txt.replace('id="companyName">Company name', f'id="companyName">{html_escape(company)}', 1) # 3. Replace the DASHBOARD_DATA block (field-guide comment + object). - payload = json.dumps(data, indent=2, ensure_ascii=False) + payload = json.dumps(data, indent=2, ensure_ascii=False).replace("