feat(linkedin-studio): N17 — baseline-motor (median + variansbånd + minimum-N-refusal) [skip-docs]
Every reading now leads with "vs your own baseline", and no verdict is given when N is too small to carry one. - stats.ts: median + medianAbsoluteDeviation (robust pair; mean/stddev stay for the alert engine, which wants outlier sensitivity), rollingBaseline with a 10-post positional window, median ± 1·MAD band floored at 0, and a typed insufficient-data refusal below MIN_BASELINE_N=5. readAgainstBaseline returns above/within/below-band, or no-verdict when the baseline was refused. - baselineByGroup + buildBaselineBlock: per-format/per-pillar baselines, each judged on its own N; the reported period is excluded from its own baseline and compared on its median, not its mean. - queue-join.ts (new): read-only date join supplying format/pillar from the post queue. Every ambiguity resolves to unlabelled, an entry labels at most one post, and a missing/broken queue degrades to no labels. - weekly/monthly reports attach the block unconditionally (refusal included); optional in the types, so pre-N17 reports load unchanged. - CLI: report output leads with the baseline; new `baseline [--by format|pillar]` verb with coverage reporting. - report.md leads with baseline framing and prints the code's reading rather than judging the band by eye; WoW loses to the baseline on disagreement. analyze.md Step 2a tests whether the drop is real before diagnosing it. TDD: 58 analytics tests written red first (144 -> 202). test-runner Section 16x, 23 unconditional checks + self-test (247 -> 270; anti-erosion floor 228 -> 251). tsc clean. All suites green: trends 300, brain 134, editions 72, specifics-bank 45, contract-gate 33, hooks 191, tests 35, render 60. Also closes the OKF phase-4 scope follow-up in docs/okf-ingestion/plan.md §8 (coord round 2026-07-25): phase 4 tracks the contract, parse is in scope, and our claim on read_concept/navigate_bundle is withdrawn as unnecessary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
parent
63506f7d5c
commit
e2ad190dda
15 changed files with 1723 additions and 14 deletions
|
|
@ -38,7 +38,34 @@ Use AskUserQuestion to understand the situation:
|
|||
|
||||
## Step 2: Gather Data
|
||||
|
||||
If imported analytics data exists (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report.
|
||||
**Step 2a — first, establish whether there is a problem at all.** This command is
|
||||
invoked because something *feels* wrong, and the operator's own framing ("my reach
|
||||
dropped") is a hypothesis, not a measurement. Diagnosing a drop that never happened
|
||||
sends them chasing a phantom and teaches them to distrust their own numbers.
|
||||
|
||||
If imported analytics exist, generate the current report and read its `baseline`
|
||||
block (see `/linkedin:report` Step 4):
|
||||
|
||||
```bash
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report
|
||||
```
|
||||
|
||||
Read `baseline.period.<metric>.reading` and lead the diagnosis with it:
|
||||
|
||||
- **`within-band`** — the numbers are inside the operator's own normal range. Say so
|
||||
before going further: *"Your last posts are inside your normal range (median X,
|
||||
normal range Y–Z). This looks like ordinary variation rather than a drop."* Then ask
|
||||
whether they still want the diagnostic pass. Often the honest answer is that nothing
|
||||
is wrong and the fix is to keep publishing — do not manufacture a diagnosis to
|
||||
justify the command having been run.
|
||||
- **`below-band`** — the drop is real and measured. Proceed, and use the band figures
|
||||
as the size of the problem instead of a remembered percentage.
|
||||
- **`no-verdict`** — print `baseline.<metric>.reason` and say plainly that there is not
|
||||
enough history to tell a drop from variation. Continue on **self-report only**, and
|
||||
label the diagnosis as provisional. Never upgrade a refusal into a verdict because
|
||||
the operator sounds worried.
|
||||
|
||||
If imported analytics exist (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report.
|
||||
|
||||
Based on their answer, ask relevant follow-up questions:
|
||||
|
||||
|
|
@ -150,7 +177,11 @@ Run through this checklist:
|
|||
|
||||
## Step 5: Reach Drop Severity Assessment
|
||||
|
||||
Based on how much reach dropped:
|
||||
Based on how much reach dropped. **Measure the drop against the baseline band from
|
||||
Step 2a, not against the single best week the operator remembers** — a percentage
|
||||
computed from a remembered peak is a comparison with an outlier, and it will class
|
||||
ordinary variation as suppression. When the band says `within-band`, the severity is
|
||||
"normal fluctuation" regardless of what the percentage says.
|
||||
|
||||
### Down <25%
|
||||
**Diagnosis:** Normal fluctuation
|
||||
|
|
|
|||
|
|
@ -142,10 +142,41 @@ The report contains:
|
|||
- **week**: ISO week identifier
|
||||
- **generatedAt**: ISO timestamp when the report was generated
|
||||
- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), optional `avgOutOfNetworkPct` (manual entry only — the impressions-weighted out-of-network share), `avgEngagementRate`, `avgImpressionsPerPost`
|
||||
- **baseline**: The operator's own normal, from posts published **before** this week —
|
||||
`window`, `required`, `historyBefore`, plus `impressions` and `engagementRate` (each
|
||||
either `{status: "ok", n, median, mad, band}` or
|
||||
`{status: "insufficient-data", n, required, reason}`), and `period` carrying this
|
||||
week's own median per metric with a `reading` of `above-band` / `within-band` /
|
||||
`below-band` / `no-verdict`. **This is what the report leads with (Step 6).**
|
||||
- **topPerformers** / **underperformers**: Best / weakest posts by engagement rate
|
||||
- **trends**: Week-over-week change — `impressionsTrend`, `engagementTrend`, `comparedTo`, `percentChange`
|
||||
- **alerts**: Anomalies and significant events (intra-week + week-over-week)
|
||||
|
||||
**Week-over-week vs baseline — which to trust.** `trends.percentChange` compares two
|
||||
adjacent weeks, so at two or three posts a week it swings wildly on sample composition
|
||||
alone; `baseline` compares against the operator's own spread. When the two disagree —
|
||||
a +38% week that reads `within-band` — **the baseline wins**, and say so explicitly:
|
||||
the percentage is real arithmetic on a sample too small to carry a conclusion.
|
||||
|
||||
## Step 4b: Per-format and per-pillar baselines (optional, when the queue has labels)
|
||||
|
||||
Format and pillar are not analytics data — they live in the post queue — so a separate
|
||||
surface joins them:
|
||||
|
||||
```bash
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" baseline
|
||||
```
|
||||
|
||||
It prints an overall baseline plus one per format and per pillar, and reports its own
|
||||
coverage (how many posts it could not label). Use it to answer "is carousel actually
|
||||
working for me?" — but respect the per-group refusals: **each group is judged on its
|
||||
own N**, so a format used twice gets no verdict even when the overall history is long.
|
||||
That refusal is the honest answer to a question the operator will still want answered;
|
||||
do not fill the gap with the overall baseline as a proxy for the group.
|
||||
|
||||
Skip this step when the join reports no labels at all — say the grouped view needs
|
||||
queue entries rather than presenting an empty table.
|
||||
|
||||
## Step 5: Run Trend Analysis
|
||||
|
||||
Get additional context with trend analysis:
|
||||
|
|
@ -253,12 +284,55 @@ Your post on [topic] achieved 12,500 impressions — a personal best!
|
|||
|
||||
## Step 6: Present Formatted Report
|
||||
|
||||
**The report LEADS with the operator's own baseline, and the absolute numbers come
|
||||
second.** At a normal publishing cadence a single week holds two or three posts, so
|
||||
an absolute total answers no question on its own — "1,240 impressions" is only
|
||||
information once you know whether that is ordinary for this operator. Present the
|
||||
baseline block first, every time, and never reorder these two sections.
|
||||
|
||||
**Do not compute the verdict yourself.** The report JSON's `baseline` block already
|
||||
carries it, decided in code: `baseline.period.<metric>.reading` is one of
|
||||
`above-band` / `within-band` / `below-band` / `no-verdict`. Print what it says.
|
||||
Judging by eye whether a number sits inside the band is the over-read this block
|
||||
exists to prevent — a value 7% above the median looks like a trend and is not one.
|
||||
|
||||
Format the data into a readable report using this template:
|
||||
|
||||
```
|
||||
# LinkedIn Performance Report
|
||||
## Week {week}
|
||||
|
||||
### 📐 Vs Your Own Baseline
|
||||
|
||||
Baseline = your last {baseline.window} posts published **before** this {week/month}
|
||||
(n={baseline.impressions.n}). Median and normal range, not mean — one viral post
|
||||
must not redefine "normal".
|
||||
|
||||
| Metric | This week (median) | Your baseline | Normal range | Reads as |
|
||||
|--------|--------------------|---------------|--------------|----------|
|
||||
| Impressions/post | {baseline.period.impressions.median} | {baseline.impressions.median} | {band.low}–{band.high} | {reading} |
|
||||
| Engagement rate | {baseline.period.engagementRate.median}% | {baseline.engagementRate.median}% | {band.low}–{band.high}% | {reading} |
|
||||
|
||||
Render `reading` in words, not as the raw token:
|
||||
- `above-band` → **above your normal range** — a real move up, worth explaining
|
||||
- `below-band` → **below your normal range** — a real move down, worth diagnosing
|
||||
- `within-band` → **inside your normal range** — normal variation, not a trend.
|
||||
Say this plainly and do not dress it up as momentum in either direction.
|
||||
- `no-verdict` → see the refusal rule below
|
||||
|
||||
**When `baseline.<metric>.status` is `insufficient-data`: refuse the verdict.** Print
|
||||
`baseline.<metric>.reason` verbatim and state that this {week/month}'s numbers are
|
||||
reported **without** a verdict. Do not substitute a week-over-week percentage as a
|
||||
stand-in, do not call it a trend, and do not soften the refusal into a hedge like
|
||||
"early signs suggest" — with fewer than {baseline.required} prior posts there is no
|
||||
signal to be early about. The absolute numbers below are still shown; only the
|
||||
verdict is withheld. An operator who has just started publishing gets this refusal
|
||||
for several weeks, and that is the correct output, not a failure of the report.
|
||||
|
||||
**If the report JSON has no `baseline` key at all**, it was generated before
|
||||
baselines existed — say so, and offer to re-run Step 3 rather than reading the
|
||||
absolutes as if they had been framed.
|
||||
|
||||
### 📊 Key Metrics
|
||||
|
||||
| Metric | Total | Average per Post | vs. Last Week |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue