feat(analytics-skill): return stored values verbatim, no cleanup

Copy source strings (titles, names, codes) character-for-character in the
answer rather than correcting typos, casing, or abbreviations; guard the rule
with a phrase in the skill-content test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q2q8GTZBn98eg6zUjDxvR
This commit is contained in:
Kevin Messiaen 2026-07-07 17:42:38 +07:00
parent 49a4ae6f5b
commit 6ccf37a418
No known key found for this signature in database
GPG key ID: 19FF750A17315202
2 changed files with 2 additions and 0 deletions

View file

@ -169,6 +169,7 @@ FROM account_txns;
- **"For each X / per X / by X"** returns exactly one row per X. Do not collapse to a single value unless the question says "overall" or "total across X".
- **A named business measure means its amount, not a row count.** When a question asks for "sales", "revenue", "spend", "value", or "volume" of money/goods without an explicit "number / count of", aggregate the monetary/quantity **amount** (`SUM(price)` / `SUM(amount)`), not `COUNT(*)` of rows. *Why:* "toy sales" reads as sales revenue; counting order rows silently answers a different question.
- **Answer literally — do not add unrequested transformations.** Apply exactly the filters, joins, grouping, and computation the question (and any `external_knowledge` doc) states; do not add "helpful" extras the task never asked for — extra status/category predicates, area/residential *weighting* of an average the question states plainly, entity-name *normalization* that forces joins the source leaves unmatched, or a re-derived value where the question names a specific stored measure/column. When the wording bounds an **aggregate** ("committees whose *total* is between $0 and $200", "entities with 5+ orders"), filter the aggregate with `HAVING`, not each row with `WHERE`. When an `external_knowledge` doc gives an explicit formula or function/UDF definition, implement it **verbatim** — same operators, constants, and ordering — rather than substituting your own "more correct" math. *Why:* each unrequested predicate silently drops valid rows, each unrequested weighting/normalization or re-derivation changes the value, and a row-level filter for an aggregate bound answers a different question — so a more-sophisticated-looking query is wrong against the literal ask. Prefer the simplest reading that satisfies the question.
- **Return stored values verbatim — reproduce them exactly as they appear in the data, do not clean them up.** When the answer lists or names values read from a column (titles, names, labels, codes, categories), copy each one **character-for-character as stored** — including apparent misspellings, odd casing, punctuation, trailing tags, and encoding quirks. Do not silently "correct" a typo (`Thorton``Thornton`), expand an abbreviation, fix capitalization, translate, or otherwise editorialize the source string; and return the **actual value the question asks for** (the title/name), never a surrogate key or id in its place. If you notice a likely error in the data, you may *note* it separately, but the reported value must remain the stored one. *Why:* the source string **is** the ground truth for a retrieval question — a "corrected" or normalized value is a different string that no longer matches the data (and any consumer matching on it), so a more-polished answer is simply wrong; faithfulness to the stored form beats looking right.
- **Don't project free-text columns the question didn't ask for.** A description/body/comment/notes column whose values contain commas or newlines corrupts the row-delimited output and is almost never the requested value — leave it out of the final projection unless the question explicitly asks for it.
- **"Inter-event duration / gap / interval" is the time between consecutive events, not a magnitude.** When the question asks the typical gap/interval/time *between* occurrences (releases, visits, orders), order rows by the event timestamp and take `LEAD`/`LAG` date differences, then aggregate — never a duration/length/runtime *column*.
- **Anchor a period bucket to the lifecycle event the wording names.** When a record carries several lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a *named completed state* by period ("delivered orders by month", "shipped items per week", "completed payments by day"), bucket the period by that named event's own timestamp (`order_delivered_customer_date`, `shipped_at`, `settled_at`) — the state value is the qualifying filter, the matching timestamp is the time anchor. Use the creation/placed/purchased/submitted timestamp only when the question names that *start* event (purchased, placed, created, ordered, submitted) or no matching event timestamp exists. If several timestamps fit, pick the one for the event as experienced by the question's subject (customer delivery = the customer-receipt date, not the carrier-handoff or estimated date). If the named state is used only as a non-temporal filter (counts by customer/city/seller with no period bucket), it is just a filter — introduce no date anchor. Confirm each timestamp's meaning from column names, semantic-layer descriptions, and sample rows first. *Why:* bucketing a completed-state count by the record's creation date silently answers a different question — "records that later reached that state, grouped by when they started" — than the one asked.

View file

@ -40,6 +40,7 @@ describe('analytics SKILL.md SQL craft', () => {
'Default by additivity', // COALESCE 0 for additive, NULL otherwise
'Keep the inputs to a derived value', // inputs alongside ratio
'Project BOTH identity and label', // entity identifier
'Return stored values verbatim', // copy source strings character-for-character, no cleanup
'Diagnose empty results', // relax filters one at a time
'Cumulative / running total', // explicit unbounded-preceding frame (spec 11)
'Rolling window over calendar time', // calendar range, not row count (spec 11)