feat(swads): add daily image report workflow

This commit is contained in:
Jeffrey Wu
2026-09-07 11:34:00 +08:00
parent f39f6ac881
commit 5c61371d7e
38 changed files with 2537 additions and 52 deletions
@@ -0,0 +1,27 @@
---
name: swads-daily-report
description: Generate a read-only TikTok GMV Max daily report with Markdown analysis and a PNG long image. Invoke explicitly with /swads-daily-report.
disable-model-invocation: true
---
# SW Ads daily report
Use only `swads_cli` to read real SW Ads data and `render_swads_daily_report` to create artifacts. Never substitute mock data, execute platform writes, request tokens in chat, or run shell commands.
## Preflight and scope
1. Call `swads_cli` with command `capabilities`, arguments `{}`. Require read-only `swads_whoami`, `metrics_catalog`, `metrics_semantic_query` and TikTok GMV Max support. Inspect returned input schemas; do not infer online capabilities from another installation. If missing, incompatible, or non-read-only, stop before rendering and explain the capability error.
2. Call `whoami` with `{}` to verify identity and authorization. Use its current account, reporting timezone, currency, and business date (use capabilities.server_time converted to that timezone if needed). Default to the most recent complete business day, with the preceding seven complete days as context. Honor account/date overrides from the text after the Slash command without changing the remote current account. Resolve overrides against visible accounts. If identity, timezone, account or platform cannot be established, ask for the missing scope and stop. First version supports TikTok GMV Max only.
3. Call `metrics.catalog` with `{"platform":"tiktok"}`. Require supported TikTok metrics/dimensions and query shapes. Missing capabilities, auth/permission errors, invalid results or unavailable data must stop generation; never render an empty or fabricated report. A missing server token is SWADS_NOT_CONFIGURED; tell the user to configure it on the server, never paste it into chat.
## Collect and analyze
- Use `metrics.query` with a nested `query` following the catalog, explicit account/timezone/date scope, and platform tiktok. Query report-day summary (spend, impressions, clicks, CTR, CPI, GMV, orders, ROAS, cost per order), the preceding seven complete daily rows, nonzero-spend campaigns and creatives (ID, title, creator, spend, GMV, orders, product impressions/clicks/CTR). Map every row using returned `columns[].name`; never assume positional order.
- Read available read-only `runtime.account-overview`, `runtime.proposals`, `runtime.config`, and `campaign.list`, passing the same account and platform. Paginate campaign.list only until all queried campaign IDs are mapped or results exhausted. Disclose unavailable optional context. Never call stateful runtime.findings: derive findings from semantic metrics if unavailable/non-read-only.
- Account status includes Spend, CTR, CPI, ROAS (GMV/spend), and simplified advertising ROI ((GMV-spend)/spend, percentage points). Missing inputs/denominators are null/N/A, never zero. Exclude product, commission, refund and fulfillment costs explicitly.
- Rank winners and anomalies by both efficiency and sample size; identify low-sample limitations in reason. Prefer active campaigns and note material disabled spend separately. Creative attribution is correlation, not causality; distinguish account-owned posts from other creators and disclose source/coverage.
- Produce exactly three prioritized, evidence-backed recommendations. List every proposed pause/resume, budget/target, pin/unpin or publishing write for manual confirmation. No platform writes are executed. A partial current day is a labeled monitoring note only.
## Deliver
Read [references/report-data-schema.md](references/report-data-schema.md) and normalize the observed data strictly, including ISO report_date. First output concise Markdown: account conclusion, key anomalies, three recommendations, dates/timezone and attribution caveats. Then call `render_swads_daily_report` with `{"data": <normalized report>}` as the final action. The server owns storage paths and rendering. PNG is the main delivery; HTML/JSON downloads provide traceability. If PNG is unavailable, surface the structured warning and available downloads; never claim an image was generated. Do not output another analysis after the render tool.
@@ -0,0 +1,141 @@
:root {
--primary: #9fe870;
--primary-pale: #e2f6d5;
--canvas: #ffffff;
--canvas-soft: #e8ebe6;
--ink: #0e0f0c;
--ink-deep: #163300;
--body: #454745;
--mute: #868685;
--line: #d4dad1;
--positive: #2ead4b;
--positive-deep: #054d28;
--warning: #ffd11a;
--warning-deep: #b86700;
--warning-bg: #fff4c2;
--warning-content: #4a3b1c;
--negative: #d03238;
--negative-deep: #a72027;
--negative-bg: #320707;
}
* { box-sizing: border-box; }
html { background: var(--canvas-soft); }
body {
width: 100%;
margin: 0;
color: var(--ink);
background: var(--canvas-soft);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-feature-settings: "calt";
font-size: 16px;
line-height: 1.5;
}
.report { width: 100%; max-width: 1320px; margin: 0 auto; padding: 56px 44px; }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 48px; align-items: center; }
.eyebrow { display: inline-block; padding: 6px 14px; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 12px; font-weight: 600; letter-spacing: .08em; }
h1 { margin: 24px 0 16px; font-size: 72px; font-weight: 900; line-height: .88; letter-spacing: -.055em; }
.subtitle { margin: 0; color: var(--body); }
.status { padding: 28px; border-radius: 24px; color: var(--primary); background: var(--ink); }
.status strong { display: block; font-size: 24px; font-weight: 600; }
.status span { display: block; margin-top: 10px; color: var(--canvas-soft); font-size: 14px; }
.kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 48px; }
.kpi { min-width: 0; min-height: 168px; padding: 24px; border-radius: 24px; background: var(--canvas); }
.kpi.accent { background: var(--primary-pale); }
.kpi-label { color: var(--body); font-size: 14px; font-weight: 600; }
.kpi-value { display: block; margin-top: 22px; font-size: 39px; font-weight: 900; line-height: .98; letter-spacing: -.04em; white-space: nowrap; }
.kpi-note { display: block; margin-top: 10px; color: var(--body); font-size: 14px; }
.good { color: var(--positive-deep); }
.bad { color: var(--negative-deep); }
.warn { color: var(--warning-deep); }
.callout { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-top: 16px; padding: 22px 24px; border-radius: 24px; color: var(--warning-content); background: var(--warning-bg); }
.callout strong { font-weight: 600; }
.callout p { margin: 3px 0 0; font-size: 14px; }
section { margin-top: 56px; }
.section-head { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 24px; }
h2 { margin: 0; font-size: 40px; font-weight: 900; line-height: .95; letter-spacing: -.04em; }
.section-head p { max-width: 560px; margin: 0; color: var(--body); font-size: 14px; text-align: right; }
.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.panel { overflow: hidden; border-radius: 24px; background: var(--canvas); }
.panel-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 22px 24px; border-bottom: 1px solid var(--line); }
.panel-title { font-weight: 600; }
.badge { padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.badge-good { color: var(--positive-deep); background: var(--primary-pale); }
.badge-bad { color: var(--canvas); background: var(--negative-bg); }
.badge-warn { color: var(--warning-content); background: var(--warning); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { padding: 12px 16px; color: var(--body); font-size: 12px; font-weight: 600; text-align: right; }
th:first-child, td:first-child { text-align: left; }
td { padding: 15px 16px; border-top: 1px solid var(--line); text-align: right; vertical-align: top; }
td:first-child { max-width: 250px; font-weight: 600; }
.row-note { display: block; margin-top: 2px; color: var(--mute); font-size: 12px; font-weight: 400; }
.trend-panel { padding: 24px; }
.trend { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; min-height: 190px; align-items: end; border-bottom: 1px solid var(--ink); }
.trend-item { display: grid; grid-template-rows: 1fr auto auto; gap: 5px; height: 100%; align-items: end; text-align: center; }
.bar { width: 38px; max-width: 78%; min-height: 20px; margin: 0 auto; border-radius: 12px 12px 0 0; background: var(--primary); }
.bar.partial { background: var(--warning); }
.trend-value { font-size: 12px; font-weight: 600; }
.trend-label { color: var(--mute); font-size: 12px; }
.source-list { padding: 24px; }
.source-row { display: grid; grid-template-columns: 180px 1fr 80px; gap: 12px; align-items: center; margin: 16px 0; font-size: 14px; }
.meter { height: 12px; overflow: hidden; border-radius: 9999px; background: var(--canvas-soft); }
.meter span { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.source-row:nth-child(2n) .meter span { background: var(--warning); }
.source-value { text-align: right; font-weight: 600; }
.insights { padding: 12px 24px; }
.insight { display: grid; grid-template-columns: 32px 1fr; gap: 12px; padding: 15px 0; border-top: 1px solid var(--line); }
.insight:first-child { border-top: 0; }
.insight-num { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 14px; font-weight: 600; }
.insight p { margin: 4px 0 0; color: var(--body); font-size: 14px; }
.recommendations { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.recommendation { min-height: 250px; padding: 24px; border-radius: 24px; background: var(--canvas); }
.recommendation-top { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.recommendation-num { font-size: 40px; font-weight: 900; line-height: 1; }
.recommendation h3 { margin: 24px 0 10px; font-size: 24px; font-weight: 600; line-height: 1.3; }
.recommendation p { margin: 0; color: var(--body); }
.confirmations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.confirmation { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 22px 24px; border-radius: 24px; background: var(--canvas); }
.confirmation strong { display: block; font-weight: 600; }
.confirmation span { display: block; margin-top: 3px; color: var(--body); font-size: 14px; }
.notes { margin: 48px 0 0; padding: 24px; border-radius: 24px; color: var(--canvas-soft); background: var(--ink); font-size: 13px; }
.notes strong { color: var(--primary); }
.notes ul { margin: 8px 0 0; padding-left: 20px; }
@media (max-width: 1023px) {
.report { padding: 40px 24px; }
.hero { grid-template-columns: 1fr; gap: 24px; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel-grid { grid-template-columns: 1fr; }
.recommendations { grid-template-columns: 1fr; }
.recommendation { min-height: 0; }
}
@media (max-width: 767px) {
.report { padding: 32px 16px; }
h1 { font-size: 48px; }
h2 { font-size: 32px; line-height: 1.15; }
.kpis { grid-template-columns: 1fr; }
.kpi { min-height: 0; }
.kpi-value { font-size: 32px; white-space: normal; }
.section-head { display: block; }
.section-head p { margin-top: 8px; text-align: left; }
.confirmations { grid-template-columns: 1fr; }
.source-row { grid-template-columns: 120px 1fr 60px; }
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* Wrap identifiers without hiding content. */
h1, h3, td, p, span, strong { overflow-wrap: anywhere; }
.report { padding-bottom: 100px; }
/* Reserve readable evidence columns even for the maximum row length. */
table { table-layout: fixed; }
th, td { padding-left: 10px; padding-right: 10px; }
th:first-child, td:first-child { width: 52%; max-width: none; }
.source-card { padding: 16px 24px; border-bottom: 1px solid var(--line); }
.source-card p { margin: 8px 0 0; color: var(--body); font-size: 14px; }
.kpi-value { white-space: normal; }
@@ -0,0 +1,74 @@
# Normalized report data
The renderer consumes normalized report data, not raw MCP responses. Build each semantic row by zipping `columns[].name` with the row values, then populate this schema. Ratios such as ROAS use multiplier units (`6.31` means `6.31×`); percentage fields use percentage points (`4.18` means `4.18%`). Money values use the account currency.
## Required shape
```json
{
"meta": {
"report_date": "2026-08-19",
"account_name": "Demo Commerce",
"account_id": "demo-account-001",
"currency": "MYR",
"reporting_timezone": "Asia/Kuala_Lumpur",
"period_label": "2026-08-19",
"comparison_label": "Previous 7 complete days",
"freshness_label": "2026-08-20 14:48 MYT",
"attribution_label": "TikTok GMV Max native attribution",
"status_label": "Cautiously positive"
},
"summary": {
"spend": 1837.84,
"revenue": 11593.35,
"orders": 663,
"ctr_percent": null,
"cpi": null,
"roas": 6.31,
"simple_roi_percent": 530.8,
"cost_per_order": 2.77
},
"monitoring_note": "Optional partial-day or data-quality note.",
"trend": [
{"label": "08/14", "roas": 6.45, "partial": false}
],
"campaigns": {
"winners": [
{"name": "Campaign name", "id": "external id", "status": "ENABLE", "spend": 10, "revenue": 80, "orders": 5, "roas": 8, "reason": "Balanced scale and efficiency"}
],
"anomalies": []
},
"creative": {
"row_count": 692,
"coverage_percent": 75.2,
"source_groups": [
{"label": "Affiliate / creator", "spend": 1232.55, "revenue": 7535.94, "orders": 463, "roas": 6.11},
{"label": "Account-owned", "spend": 150.2, "revenue": 121, "orders": 8, "roas": 0.81}
],
"winners": [
{"name": "Creative title", "id": "creative id", "creator": "creator", "spend": 20, "revenue": 350, "orders": 20, "roas": 17.5, "clicks": 89, "product_ctr_percent": 7.0}
],
"anomalies": [],
"insights": ["Short evidence-backed attribution clue"]
},
"recommendations": [
{"title": "Action title", "detail": "Observed evidence and bounded next step.", "confirmation": "required"}
],
"manual_confirmations": [
{"action": "Pause or change budget", "required": true, "reason": "Changes live delivery"}
],
"notes": ["Attribution and unavailable-data caveats"]
}
```
## Completion rules
- `recommendations` contains exactly three items. `confirmation` is `required`, `partial`, or `none`.
- Winner and anomaly tables should each contain three to five rows when sufficient data exists. Keep low-sample rows but identify the sample limitation in `reason`.
- Missing CTR/CPI/ROI inputs are JSON `null`. Never encode missing data as numeric zero.
- `creative.source_groups` may be empty when creator identity is unavailable. Keep `creative.insights` evidence-based and avoid causal language.
- `manual_confirmations` covers every recommended platform write. Read-only checks may use `required: false`.
## Runtime bounds
All objects are strict. report_date must be a real ISO YYYY-MM-DD date; timezone an IANA timezone; currency a three-letter code. Text fields: maximum 500 characters (names/IDs/labels 160). Arrays: trend 7, each winner/anomaly table 5, source_groups 6, insights 4, notes and manual_confirmations 10. Metrics are finite numbers or null; counts are nonnegative integers. Campaign reason is required; creative reason is optional for sample caveats. Required/partial recommendations need a required manual confirmation entry. Reports require non-null spend and at least one other observed summary metric.