skilldrop-cli 0.16.28 → 0.16.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/skilldrop.js +160 -33
- package/catalogue.json +7 -3
- package/model-routing.json +16 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/mcp-pairing/SKILL.md +93 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/eval_queries.json +13 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/evals.json +76 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/2/mcp.json +32 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/.mcp.json +47 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/docs/pr-loop.md +12 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/5/docs/oncall-triage.md +18 -0
- package/packs/ai-engineering/skills/mcp-pairing/examples/build-loop-github-jira-datadog.md +69 -0
- package/packs/ai-engineering/skills/mcp-pairing/manifest.json +17 -0
- package/packs/ai-engineering/skills/mcp-pairing/reference.md +136 -0
- package/packs/ai-engineering/skills/mcp-pairing/templates/mcp-pairing.md +43 -0
- package/packs/data-analytics/loops/measure/LOOP.md +118 -0
- package/packs/data-analytics/loops/measure/evals/eval_queries.json +9 -0
- package/packs/data-analytics/loops/measure/evals/evals.json +28 -0
- package/packs/data-analytics/loops/measure/loop.json +41 -0
- package/packs/dev-team/loops/build/LOOP.md +4 -2
- package/packs/dev-team/loops/build/loop.json +2 -2
- package/packs/dev-team/skills/codebase-tour/SKILL.md +115 -0
- package/packs/dev-team/skills/codebase-tour/evals/eval_queries.json +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/evals.json +77 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/.github/workflows/ci.yml +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/README.md +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/openapi.yaml +21 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/package-lock.json +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/package.json +29 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/config.ts +20 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/filter.ts +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/repository.ts +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/errors.ts +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/generated/api-types.ts +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/handlers/shipments.ts +38 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/routes.ts +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/jobs/export-shipments.ts +22 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/server.ts +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/domain/filter.test.ts +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/http/shipments.test.ts +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tsconfig.json +10 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/vitest.config.ts +5 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/.github/workflows/ci.yml +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/README.md +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/noxfile.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/pyproject.toml +22 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/__init__.py +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/_version.py +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/cli.py +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/detect.py +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/errors.py +2 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/money.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/__init__.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/acme.py +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/base.py +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/harbor.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/fixtures/acme_sample.csv +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_acme.py +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_detect.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/.github/workflows/ci.yml +28 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/Makefile +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/README.md +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/cmd/tidepool/main.go +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/go.mod +5 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/config/config.go +38 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher.go +52 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher_test.go +42 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/sender.go +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/replay/replay.go +31 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/db.go +35 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql.go +30 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/schema.sql +6 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/store_integration_test.go +21 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/sqlc.yaml +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/.env.example +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/.github/workflows/ci.yml +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/Makefile +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/README.md +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/data/rates.csv +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/pyproject.toml +19 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/scripts/gen_rates.py +29 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/preview.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/routes.py +36 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/config.py +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/discounts.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/rates_table.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/main.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/shipping.py +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/tax.py +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/conftest.py +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_discounts.py +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_tax.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/.env.example +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/.github/workflows/ci.yml +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/README.md +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/package.json +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/openapi.json +30 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/package.json +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/db.js +19 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/flags.js +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/generated/schema.js +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/middleware/validate.js +20 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/routes/bookings.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/server.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/bookings.js +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/pricing.js +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/test/pricing.test.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/package.json +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/bookingForm.js +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/format.js +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/test/format.test.js +12 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/scripts/gen-schema.mjs +27 -0
- package/packs/dev-team/skills/codebase-tour/examples/refund-window.md +158 -0
- package/packs/dev-team/skills/codebase-tour/manifest.json +47 -0
- package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +403 -0
- package/packs/dev-team/skills/codebase-tour/templates/codebase-tour.md +85 -0
- package/packs/dev-team/skills/dependency-upgrade-review/SKILL.md +101 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/eval_queries.json +11 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/evals.json +77 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/.github/workflows/ci.yml +20 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/pr.diff +87 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/quillmark-CHANGELOG.md +24 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/comment.js +6 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/note.js +8 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/test/note.test.js +12 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/ci.yml +23 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/release.yml +23 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/pr.diff +32 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/release-notes.md +20 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/.github/workflows/ci.yml +18 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/Dockerfile +8 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/PR.md +36 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/pr.diff +47 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/client.py +24 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/reader.py +32 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/tests/test_reader.py +15 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/upstream/quillstream-CHANGELOG.md +37 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/ci.yml +22 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/release.yml +25 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.nvmrc +1 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-412.md +16 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-413.md +15 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/package.json +21 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-412.diff +26 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-413.diff +84 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/scripts/release.mjs +18 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/release-notary-CHANGELOG.md +16 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/setup-toolchain-RELEASES.md +22 -0
- package/packs/dev-team/skills/dependency-upgrade-review/examples/inputs/ledgerline.diff +38 -0
- package/packs/dev-team/skills/dependency-upgrade-review/examples/ledgerline-tidyparse.md +91 -0
- package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +50 -0
- package/packs/dev-team/skills/dependency-upgrade-review/reference.md +99 -0
- package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +718 -0
- package/packs/grc/loops/comply/LOOP.md +133 -0
- package/packs/grc/loops/comply/evals/eval_queries.json +9 -0
- package/packs/grc/loops/comply/evals/evals.json +30 -0
- package/packs/grc/loops/comply/loop.json +54 -0
- package/packs/product-manager/loops/discover/LOOP.md +5 -4
- package/packs/product-manager/loops/discover/loop.json +2 -2
- package/packs/product-manager/skills/feedback-synthesis/SKILL.md +182 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/eval_queries.json +46 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/evals.json +76 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/interview-notes.md +47 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/nps-verbatims.csv +13 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/support-tickets.csv +36 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/3/churn-exit-survey.csv +11 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/3/sales-call-notes.md +55 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/4/csat_comments_q3.csv +17 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/4/support_tickets_q3.csv +49 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/5/app_reviews_last_60_days.csv +32 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/5/interview_notes_round1.md +48 -0
- package/packs/product-manager/skills/feedback-synthesis/examples/booking-app-feedback.md +139 -0
- package/packs/product-manager/skills/feedback-synthesis/examples/inputs/booking-app-coded.csv +17 -0
- package/packs/product-manager/skills/feedback-synthesis/manifest.json +53 -0
- package/packs/product-manager/skills/feedback-synthesis/reference.md +89 -0
- package/packs/product-manager/skills/feedback-synthesis/scripts/theme_counts.py +355 -0
- package/packs/product-manager/skills/feedback-synthesis/templates/coding-sheet.csv +6 -0
- package/packs/product-manager/skills/feedback-synthesis/templates/synthesis.md +42 -0
- package/packs/research/loops/research/LOOP.md +123 -0
- package/packs/research/loops/research/evals/eval_queries.json +9 -0
- package/packs/research/loops/research/evals/evals.json +39 -0
- package/packs/research/loops/research/loop.json +35 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# feedback-synthesis — reference
|
|
2
|
+
|
|
3
|
+
## Counting
|
|
4
|
+
|
|
5
|
+
- **The unit is a person or an account, stated once.** B2B with account IDs: accounts. B2C,
|
|
6
|
+
or B2B with no account IDs: people. Say which in the headline or the theme table header.
|
|
7
|
+
- **Same ID, same person, across sources.** An interviewee identified as account A-1042 and the
|
|
8
|
+
tickets from A-1042 are one account. Use the same `person_id` in every row.
|
|
9
|
+
- **No ID, own person.** A row with a blank `person_id` counts as its own person; the script
|
|
10
|
+
labels these. When a theme is mostly anonymous rows, its count is a count of mentions and the
|
|
11
|
+
reply says so.
|
|
12
|
+
- **Display names are weak IDs.** App-store reviewers with the same name and a consistent
|
|
13
|
+
story (same device, same complaint, "updating my review") are treated as one; record it as
|
|
14
|
+
an assumption. Two different stories under a common first name are two people.
|
|
15
|
+
- **Anonymous beside identified.** "3 accounts, plus one anonymous comment that may be one of
|
|
16
|
+
them" — never 4.
|
|
17
|
+
- **A reporter speaking for others is one source, not many.** "Two of our clients in France
|
|
18
|
+
couldn't pay" is one account reporting; the count of affected end users goes in the evidence
|
|
19
|
+
as what the account said, not in the people count.
|
|
20
|
+
- **Repeat filing is evidence of cost, not of breadth.** Six tickets from one account say the
|
|
21
|
+
problem is costing that account; they don't say six customers have it. Report the repeat
|
|
22
|
+
filing as severity evidence ("raised six times in two months").
|
|
23
|
+
|
|
24
|
+
## Severity scale
|
|
25
|
+
|
|
26
|
+
Rate each item from what happened, not tone.
|
|
27
|
+
|
|
28
|
+
| Level | Rule | Edge cases |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| S1 — blocker | A core job can't be done, or money, data, security, privacy or compliance is at stake | A payment that fails and is later made another way is still S1: the job failed in the product. A message sent to the wrong recipient is S2 unless the person says it exposed sensitive data. |
|
|
31
|
+
| S2 — major | The job is done only through a costly recurring workaround, or the person says they will leave over it | "Thinking about moving to another tool" lifts an otherwise S3 item to S2. A one-off workaround that took an hour is S3. |
|
|
32
|
+
| S3 — minor | Slower, confusing or annoying; the workaround is quick | A how-to question is S3 when the person couldn't find something that exists; it is not a severity at all when they just asked. |
|
|
33
|
+
| S4 — cosmetic | Appearance, wording or preference with no stated cost | "Looks unprofessional" stays S4 unless the person says it cost them a client. |
|
|
34
|
+
|
|
35
|
+
A theme's severity is the worst level at least two people reached (that level or worse). A
|
|
36
|
+
theme raised by one person takes that person's level, labelled *one report — confirm before
|
|
37
|
+
acting*. Report the spread ("S1 for 3
|
|
38
|
+
accounts; S3 for 1") when it differs.
|
|
39
|
+
|
|
40
|
+
## Sampling bias by source
|
|
41
|
+
|
|
42
|
+
| Source | Who is in it | What it can't support |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Support tickets | People who hit a problem and chose to write in; heavy users and the angriest over-represented | How common a problem is across all customers; what satisfied customers think |
|
|
45
|
+
| NPS / CSAT verbatims | Respondents who left a comment; strong opinions at both ends over-respond, and detractors write more | Satisfaction of the base; comparing themes by share of responses as if it were share of customers |
|
|
46
|
+
| App-store reviews | People moved to review, often right after a crash or a prompt; no IDs | Anything per user; retention; why non-reviewers stay |
|
|
47
|
+
| Interviews | Whoever agreed to talk, often recruited from engaged customers; small n | Frequency of anything; "4 of 5 interviewees" is a story count, not a rate |
|
|
48
|
+
| Sales-call notes | Prospects, filtered through a rep's summary; skews to those who asked for a demo and to what reps listen for | What existing customers need; prospect words, unless in quotation marks |
|
|
49
|
+
| Churn / exit surveys | Leavers who answered; the first reason ticked, often the easiest | Why people stay; reasons of segments that didn't churn in the period |
|
|
50
|
+
| Community / social posts | The vocal few; public posts perform | Anything per customer without IDs |
|
|
51
|
+
|
|
52
|
+
Always name, per source used, who is in it and one conclusion it can't support. Never turn a
|
|
53
|
+
self-selected count into a percentage of the user base. A share of the sample is allowed when
|
|
54
|
+
labelled as such: ✅ *"5 of the 10 exit-survey respondents"*.
|
|
55
|
+
|
|
56
|
+
## Rewriting a request into a problem
|
|
57
|
+
|
|
58
|
+
Ask of each request: *what was the person trying to do when they wanted this, and what does
|
|
59
|
+
not having it cost them?* Use only what they said.
|
|
60
|
+
|
|
61
|
+
| Request as given | Problem statement (when the item says why) | When the item doesn't say why |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| "Please add bulk edit for recurring invoices." + "had to open 58 recurring invoices one at a time" | Changing a rate means editing every recurring invoice by hand | — |
|
|
64
|
+
| "Add dark mode." | — | Request with no stated need; interview question: *"When do you use the app, and what's hard about it then?"* |
|
|
65
|
+
| "We need SSO with Entra." | Enterprise IT won't approve a rollout without central sign-in | — |
|
|
66
|
+
| "Make it work offline." + "half our jobs are in plant rooms" | Technicians lose access to job details where there's no signal | — |
|
|
67
|
+
|
|
68
|
+
The request stays in the evidence as a quote. Two different requests can sit under one problem,
|
|
69
|
+
and one request can point at two problems; code both.
|
|
70
|
+
|
|
71
|
+
## Codebook rules
|
|
72
|
+
|
|
73
|
+
- 4–12 problem themes plus the valued (`+`) themes. Fewer than 4 from 30+ items means themes are
|
|
74
|
+
too broad; more than 12 means they are too narrow to act on.
|
|
75
|
+
- One sentence each: who, what happens, what it costs. Code is short kebab-case.
|
|
76
|
+
- An item can carry several codes. An item that fits no theme stays unthemed; don't stretch a
|
|
77
|
+
theme to swallow it.
|
|
78
|
+
- How-to questions are coded only when several point at one gap ("can't find reports after the
|
|
79
|
+
update"); otherwise they are unthemed and listed by count.
|
|
80
|
+
- Contradictions are their own finding, not a reason to drop a theme: code both sides.
|
|
81
|
+
|
|
82
|
+
## Interview questions
|
|
83
|
+
|
|
84
|
+
Tied to a theme, open, about past behaviour, not hypothetical, not leading:
|
|
85
|
+
|
|
86
|
+
- ✅ *"Tell me about the last time you changed your prices. What did you do in the product?"*
|
|
87
|
+
- ✅ Disconfirming: *"Which clients pay by card without trouble? What's different about them?"*
|
|
88
|
+
- ❌ *"Would you pay more for bulk edit?"* — hypothetical and leading
|
|
89
|
+
- ❌ *"Is dark mode important to you?"* — yes/no, and the answer is always yes
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Count coded customer feedback by distinct person, not by mention.
|
|
3
|
+
|
|
4
|
+
Reads a coding sheet: one row per feedback item (a ticket, a survey verbatim, an interview
|
|
5
|
+
note, a review) that an agent or analyst has already tagged with theme codes. Prints, per
|
|
6
|
+
theme:
|
|
7
|
+
- distinct people (or accounts) and mentions (items), and which people filed several
|
|
8
|
+
- the sources it came from, with distinct people per source
|
|
9
|
+
- the segment breakdown for every segment column, and a flag when a theme lives in one
|
|
10
|
+
segment value only
|
|
11
|
+
- distinct people at each severity level, and the worst level reported
|
|
12
|
+
and then the items with no theme, the people who filed most, and the rows whose person ID
|
|
13
|
+
is missing (those count as one person each, so their numbers are mentions).
|
|
14
|
+
|
|
15
|
+
Columns (header names, case-insensitive):
|
|
16
|
+
item_id required ticket / response / note ID, quoted back as the source of a quote
|
|
17
|
+
person_id optional person or account ID; blank -> the row counts as its own person
|
|
18
|
+
source optional tickets, nps, interview, app-store, sales-call ...
|
|
19
|
+
themes required theme codes separated by ";". A code may carry its own severity
|
|
20
|
+
as code:S1, overriding the row's severity. A code starting with "+"
|
|
21
|
+
is something people value (no severity).
|
|
22
|
+
severity optional S1 (blocker) .. S4 (cosmetic); blank -> unrated
|
|
23
|
+
any other column is a segment (plan, role, region, tenure ...) unless named in --ignore.
|
|
24
|
+
Columns called quote, text, notes, comment, date, created or summary are ignored by default.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
python3 theme_counts.py coded.csv
|
|
28
|
+
python3 theme_counts.py coded.csv --person-col account_id -o counts.md
|
|
29
|
+
python3 theme_counts.py coded.csv --segment plan --segment region --json counts.json
|
|
30
|
+
|
|
31
|
+
Stdlib only; Python 3.9+. Reads only the CSV it is given; writes only the paths named with
|
|
32
|
+
-o / --json.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import csv
|
|
39
|
+
import json
|
|
40
|
+
import sys
|
|
41
|
+
from collections import Counter, defaultdict
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
SEVERITIES = ["S1", "S2", "S3", "S4"]
|
|
45
|
+
SEVERITY_ALIASES = {
|
|
46
|
+
"s1": "S1", "1": "S1", "blocker": "S1", "critical": "S1",
|
|
47
|
+
"s2": "S2", "2": "S2", "major": "S2", "high": "S2",
|
|
48
|
+
"s3": "S3", "3": "S3", "minor": "S3", "medium": "S3",
|
|
49
|
+
"s4": "S4", "4": "S4", "cosmetic": "S4", "low": "S4", "trivial": "S4",
|
|
50
|
+
}
|
|
51
|
+
DEFAULT_IGNORE = {"quote", "text", "notes", "note", "comment", "comments", "date", "created",
|
|
52
|
+
"summary", "body", "description", "subject", "title", "score"}
|
|
53
|
+
CORE = {"item_id", "person_id", "source", "themes", "severity"}
|
|
54
|
+
NO_ID = "(no id: "
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def die(msg: str) -> None:
|
|
58
|
+
sys.stderr.write("error: " + msg + "\n")
|
|
59
|
+
sys.exit(2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def norm_sev(value: str, where: str, warnings: list) -> str:
|
|
63
|
+
v = (value or "").strip()
|
|
64
|
+
if not v:
|
|
65
|
+
return ""
|
|
66
|
+
s = SEVERITY_ALIASES.get(v.lower())
|
|
67
|
+
if not s:
|
|
68
|
+
warnings.append(f"{where}: unknown severity '{v}' treated as unrated")
|
|
69
|
+
return ""
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load(path: Path, person_col: str, ignore: set, segments_wanted: list):
|
|
74
|
+
try:
|
|
75
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
76
|
+
except OSError as e:
|
|
77
|
+
die(f"cannot read {path}: {e}")
|
|
78
|
+
reader = csv.DictReader(text.splitlines())
|
|
79
|
+
if not reader.fieldnames:
|
|
80
|
+
die(f"{path} has no header row")
|
|
81
|
+
header = {h.strip().lower(): h for h in reader.fieldnames if h}
|
|
82
|
+
person_key = person_col.lower()
|
|
83
|
+
for need in ("item_id", "themes"):
|
|
84
|
+
if need not in header:
|
|
85
|
+
die(f"{path} needs a '{need}' column (found: {', '.join(reader.fieldnames)})")
|
|
86
|
+
if person_col != "person_id" and person_key not in header:
|
|
87
|
+
die(f"--person-col '{person_col}' is not a column in {path}")
|
|
88
|
+
core = (CORE - {"person_id"}) | {person_key}
|
|
89
|
+
if segments_wanted:
|
|
90
|
+
segs = [s.lower() for s in segments_wanted]
|
|
91
|
+
for s in segs:
|
|
92
|
+
if s not in header:
|
|
93
|
+
die(f"segment column '{s}' is not in {path}")
|
|
94
|
+
else:
|
|
95
|
+
segs = [h for h in header if h not in core and h not in ignore and h != "person_id"]
|
|
96
|
+
|
|
97
|
+
warnings: list = []
|
|
98
|
+
rows = []
|
|
99
|
+
seen_ids = set()
|
|
100
|
+
for n, raw in enumerate(reader, start=2):
|
|
101
|
+
r = {k.strip().lower(): (v or "").strip() for k, v in raw.items() if k}
|
|
102
|
+
item = r.get("item_id", "")
|
|
103
|
+
if not item:
|
|
104
|
+
warnings.append(f"line {n}: no item_id; row skipped")
|
|
105
|
+
continue
|
|
106
|
+
if item in seen_ids:
|
|
107
|
+
warnings.append(f"line {n}: item_id {item} appears twice; both rows counted")
|
|
108
|
+
seen_ids.add(item)
|
|
109
|
+
pid = r.get(person_key, "")
|
|
110
|
+
row_sev = norm_sev(r.get("severity", ""), f"{item}", warnings)
|
|
111
|
+
codes = []
|
|
112
|
+
for c in r.get("themes", "").split(";"):
|
|
113
|
+
c = c.strip()
|
|
114
|
+
if not c:
|
|
115
|
+
continue
|
|
116
|
+
sev = row_sev
|
|
117
|
+
if ":" in c:
|
|
118
|
+
c, _, s = c.partition(":")
|
|
119
|
+
c = c.strip()
|
|
120
|
+
sev = norm_sev(s, f"{item} theme {c}", warnings)
|
|
121
|
+
if c.startswith("+"):
|
|
122
|
+
sev = ""
|
|
123
|
+
if c:
|
|
124
|
+
codes.append((c, sev))
|
|
125
|
+
rows.append({
|
|
126
|
+
"item": item,
|
|
127
|
+
"person": pid or f"{NO_ID}{item})",
|
|
128
|
+
"has_id": bool(pid),
|
|
129
|
+
"source": r.get("source", "") or "(none)",
|
|
130
|
+
"segments": {s: r.get(s, "") for s in segs},
|
|
131
|
+
"codes": codes,
|
|
132
|
+
})
|
|
133
|
+
return rows, segs, warnings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def analyse(rows, segs):
|
|
137
|
+
people_seg = defaultdict(dict) # person -> seg -> value (first non-empty)
|
|
138
|
+
seg_conflicts = defaultdict(set)
|
|
139
|
+
for r in rows:
|
|
140
|
+
for s, v in r["segments"].items():
|
|
141
|
+
if not v:
|
|
142
|
+
continue
|
|
143
|
+
have = people_seg[r["person"]].get(s)
|
|
144
|
+
if have is None:
|
|
145
|
+
people_seg[r["person"]][s] = v
|
|
146
|
+
elif have != v:
|
|
147
|
+
seg_conflicts[(r["person"], s)].update({have, v})
|
|
148
|
+
|
|
149
|
+
all_values = {s: Counter() for s in segs}
|
|
150
|
+
for p, d in people_seg.items():
|
|
151
|
+
for s, v in d.items():
|
|
152
|
+
all_values[s][v] += 1
|
|
153
|
+
|
|
154
|
+
themes = defaultdict(lambda: {"items": [], "people": Counter(), "sources": defaultdict(set),
|
|
155
|
+
"sev_people": defaultdict(set)})
|
|
156
|
+
for r in rows:
|
|
157
|
+
for code, sev in r["codes"]:
|
|
158
|
+
t = themes[code]
|
|
159
|
+
t["items"].append(r["item"])
|
|
160
|
+
t["people"][r["person"]] += 1
|
|
161
|
+
t["sources"][r["source"]].add(r["person"])
|
|
162
|
+
t["sev_people"][sev or "unrated"].add(r["person"])
|
|
163
|
+
|
|
164
|
+
out = []
|
|
165
|
+
for code, t in themes.items():
|
|
166
|
+
people = list(t["people"])
|
|
167
|
+
seg_break = {}
|
|
168
|
+
single = []
|
|
169
|
+
for s in segs:
|
|
170
|
+
c = Counter(people_seg[p].get(s, "(blank)") for p in people)
|
|
171
|
+
seg_break[s] = dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
172
|
+
known = [v for v in c if v != "(blank)"]
|
|
173
|
+
known_people = sum(n for v, n in c.items() if v != "(blank)")
|
|
174
|
+
total = sum(all_values[s].values())
|
|
175
|
+
# Flag a theme that sits in one segment value only, unless that value is most of
|
|
176
|
+
# the whole sample anyway (a UK-only theme in a 90% UK sample says nothing).
|
|
177
|
+
if (known_people >= 2 and len(known) == 1 and len(all_values[s]) >= 2
|
|
178
|
+
and all_values[s][known[0]] / total < 2 / 3):
|
|
179
|
+
blank = c.get("(blank)", 0)
|
|
180
|
+
single.append(f"{s}={known[0]} ({all_values[s][known[0]]} of {total} overall"
|
|
181
|
+
+ (f"; {blank} blank here" if blank else "") + ")")
|
|
182
|
+
# A person's severity for a theme is the worst level they reported for it.
|
|
183
|
+
worst_by_person = {}
|
|
184
|
+
for sev in SEVERITIES + ["unrated"]:
|
|
185
|
+
for p in t["sev_people"].get(sev, ()):
|
|
186
|
+
worst_by_person.setdefault(p, sev)
|
|
187
|
+
sev_counts = Counter(worst_by_person.values())
|
|
188
|
+
worst = next((s for s in SEVERITIES if sev_counts.get(s)), "")
|
|
189
|
+
# Ranking severity: the worst level that at least two people reached (that level or
|
|
190
|
+
# worse). A theme raised by one person takes that person's level, marked "1 report".
|
|
191
|
+
rank_sev, running = "", 0
|
|
192
|
+
for s in SEVERITIES:
|
|
193
|
+
running += sev_counts.get(s, 0)
|
|
194
|
+
if running >= 2:
|
|
195
|
+
rank_sev = s
|
|
196
|
+
break
|
|
197
|
+
if not rank_sev and len(people) == 1 and worst:
|
|
198
|
+
rank_sev = worst + " (1 report)"
|
|
199
|
+
elif not rank_sev and worst:
|
|
200
|
+
rank_sev = worst + " (1 rated)"
|
|
201
|
+
top_person, top_n = t["people"].most_common(1)[0]
|
|
202
|
+
no_id = [p for p in people if p.startswith(NO_ID)]
|
|
203
|
+
out.append({
|
|
204
|
+
"theme": code,
|
|
205
|
+
"valued": code.startswith("+"),
|
|
206
|
+
# people = distinct people with an ID. Rows with no ID are reported beside it,
|
|
207
|
+
# never added to it: an anonymous comment may come from someone already counted.
|
|
208
|
+
"people": len(people) - len(no_id),
|
|
209
|
+
"no_id_items": len(no_id),
|
|
210
|
+
"mentions": len(t["items"]),
|
|
211
|
+
"repeat_filers": {p: n for p, n in t["people"].most_common() if n > 1},
|
|
212
|
+
"top_person_share": round(top_n / len(t["items"]), 2),
|
|
213
|
+
"top_person": top_person,
|
|
214
|
+
"sources": {s: {"people": sum(1 for p in ps if not p.startswith(NO_ID)),
|
|
215
|
+
"no_id_items": sum(1 for p in ps if p.startswith(NO_ID))}
|
|
216
|
+
for s, ps in sorted(t["sources"].items())},
|
|
217
|
+
"segments": seg_break,
|
|
218
|
+
"single_segment": single,
|
|
219
|
+
"severity_people": {s: sev_counts[s] for s in SEVERITIES + ["unrated"] if sev_counts.get(s)},
|
|
220
|
+
"worst_severity": worst,
|
|
221
|
+
"ranking_severity": "" if code.startswith("+") else rank_sev,
|
|
222
|
+
"items": t["items"],
|
|
223
|
+
})
|
|
224
|
+
sev_rank = {s: i for i, s in enumerate(SEVERITIES)}
|
|
225
|
+
out.sort(key=lambda d: (d["valued"], sev_rank.get(d["ranking_severity"][:2], 9), -d["people"],
|
|
226
|
+
-d["no_id_items"], d["theme"]))
|
|
227
|
+
|
|
228
|
+
per_person = Counter(r["person"] for r in rows)
|
|
229
|
+
summary = {
|
|
230
|
+
"items": len(rows),
|
|
231
|
+
"people": len({r["person"] for r in rows if r["has_id"]}),
|
|
232
|
+
"items_without_person_id": sum(1 for r in rows if not r["has_id"]),
|
|
233
|
+
"sources": {s: {"items": n, "people": len({r["person"] for r in rows if r["source"] == s and r["has_id"]}),
|
|
234
|
+
"no_id_items": sum(1 for r in rows if r["source"] == s and not r["has_id"])}
|
|
235
|
+
for s, n in sorted(Counter(r["source"] for r in rows).items())},
|
|
236
|
+
"segment_values": {s: dict(all_values[s]) for s in segs},
|
|
237
|
+
"most_items_per_person": {p: n for p, n in per_person.most_common(5) if n > 1 and not p.startswith(NO_ID)},
|
|
238
|
+
"unthemed_items": [r["item"] for r in rows if not r["codes"]],
|
|
239
|
+
"segment_conflicts": {f"{p} {s}": sorted(v) for (p, s), v in seg_conflicts.items()},
|
|
240
|
+
}
|
|
241
|
+
return summary, out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def fmt_counts(d: dict) -> str:
|
|
245
|
+
return ", ".join(f"{k} {v}" for k, v in d.items()) or "-"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def fmt_sources(d: dict) -> str:
|
|
249
|
+
parts = []
|
|
250
|
+
for s, v in d.items():
|
|
251
|
+
bits = []
|
|
252
|
+
if v["people"]:
|
|
253
|
+
bits.append(str(v["people"]))
|
|
254
|
+
if v["no_id_items"]:
|
|
255
|
+
bits.append(f"+{v['no_id_items']} no-ID")
|
|
256
|
+
parts.append(f"{s} {' '.join(bits)}")
|
|
257
|
+
return ", ".join(parts) or "-"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render(summary, themes, person_label: str, warnings) -> str:
|
|
261
|
+
plural = "people" if person_label == "person" else person_label + "s"
|
|
262
|
+
L = []
|
|
263
|
+
L.append("# Theme counts")
|
|
264
|
+
L.append("")
|
|
265
|
+
L.append(f"- Items: {summary['items']}; distinct {plural} with an ID: {summary['people']}")
|
|
266
|
+
if summary["items_without_person_id"]:
|
|
267
|
+
L.append(f"- {summary['items_without_person_id']} item(s) have no {person_label} ID. They are counted "
|
|
268
|
+
f"in the No-ID column, never added to the {person_label} count: each may come from someone "
|
|
269
|
+
"already counted, so they are mentions, not people.")
|
|
270
|
+
L.append("- By source: " + "; ".join(
|
|
271
|
+
f"{s} {v['items']} items / {v['people']} {plural}" + (f" + {v['no_id_items']} no-ID" if v["no_id_items"] else "")
|
|
272
|
+
for s, v in summary["sources"].items()))
|
|
273
|
+
for s, vals in summary["segment_values"].items():
|
|
274
|
+
L.append(f"- {s} across all {plural} and no-ID items: {fmt_counts(dict(sorted(vals.items())))}")
|
|
275
|
+
if summary["most_items_per_person"]:
|
|
276
|
+
L.append(f"- Most items from one {person_label}: {fmt_counts(summary['most_items_per_person'])}")
|
|
277
|
+
L.append("")
|
|
278
|
+
L.append("Ranked by ranking severity (the worst level at least two people reached), then by "
|
|
279
|
+
f"distinct {plural}. Themes starting with + are things people value.")
|
|
280
|
+
L.append("")
|
|
281
|
+
L.append(f"| Theme | {plural.capitalize()} | No-ID items | Mentions | Ranking severity | Worst | "
|
|
282
|
+
f"By severity | Sources | One segment only |")
|
|
283
|
+
L.append("|---|---|---|---|---|---|---|---|---|")
|
|
284
|
+
for t in themes:
|
|
285
|
+
sev_cells = ("-", "-", "-") if t["valued"] else (
|
|
286
|
+
t["ranking_severity"] or "-", t["worst_severity"] or "-", fmt_counts(t["severity_people"]))
|
|
287
|
+
L.append(f"| {t['theme']} | {t['people']} | {t['no_id_items']} | {t['mentions']} | {sev_cells[0]} | "
|
|
288
|
+
f"{sev_cells[1]} | {sev_cells[2]} | {fmt_sources(t['sources'])} | "
|
|
289
|
+
f"{', '.join(t['single_segment']) or '-'} |")
|
|
290
|
+
L.append("")
|
|
291
|
+
for t in themes:
|
|
292
|
+
L.append(f"## {t['theme']}")
|
|
293
|
+
L.append(f"- {t['people']} {person_label}(s) with an ID"
|
|
294
|
+
+ (f", plus {t['no_id_items']} item(s) with no ID" if t["no_id_items"] else "")
|
|
295
|
+
+ f"; {t['mentions']} mention(s)")
|
|
296
|
+
L.append(f"- Ranking severity: {t['ranking_severity'] or '-'}" if not t["valued"] else "- Valued (no severity)")
|
|
297
|
+
if t["repeat_filers"]:
|
|
298
|
+
L.append(f"- Repeat filers: {fmt_counts(t['repeat_filers'])}")
|
|
299
|
+
if t["mentions"] >= 3 and t["top_person_share"] > 0.5:
|
|
300
|
+
L.append(f"- WARNING: {t['top_person']} accounts for {int(t['top_person_share'] * 100)}% of this theme's mentions")
|
|
301
|
+
for s, br in t["segments"].items():
|
|
302
|
+
L.append(f"- {s}: {fmt_counts(br)}")
|
|
303
|
+
if t["single_segment"]:
|
|
304
|
+
L.append(f"- Only in: {', '.join(t['single_segment'])}")
|
|
305
|
+
if not t["valued"]:
|
|
306
|
+
L.append(f"- Severity (worst level each {person_label} or no-ID item reported): "
|
|
307
|
+
f"{fmt_counts(t['severity_people'])}")
|
|
308
|
+
L.append(f"- Items: {', '.join(t['items'])}")
|
|
309
|
+
L.append("")
|
|
310
|
+
L.append("## Items with no theme")
|
|
311
|
+
L.append(", ".join(summary["unthemed_items"]) or "None")
|
|
312
|
+
if summary["segment_conflicts"]:
|
|
313
|
+
L.append("")
|
|
314
|
+
L.append("## Segment conflicts (same ID, different values; first value used)")
|
|
315
|
+
for k, v in summary["segment_conflicts"].items():
|
|
316
|
+
L.append(f"- {k}: {', '.join(v)}")
|
|
317
|
+
if warnings:
|
|
318
|
+
L.append("")
|
|
319
|
+
L.append("## Warnings")
|
|
320
|
+
L.extend(f"- {w}" for w in warnings)
|
|
321
|
+
return "\n".join(L) + "\n"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main(argv=None) -> int:
|
|
325
|
+
ap = argparse.ArgumentParser(description="Count coded feedback by distinct person per theme.")
|
|
326
|
+
ap.add_argument("csv", type=Path, help="coding sheet: item_id, person_id, source, themes, severity, segment columns")
|
|
327
|
+
ap.add_argument("--person-col", default="person_id",
|
|
328
|
+
help="column that identifies a person or account (default person_id)")
|
|
329
|
+
ap.add_argument("--person-label", default=None, help="word for the counted unit, e.g. account (default: person, or the column name)")
|
|
330
|
+
ap.add_argument("--segment", action="append", default=[], help="segment column to break down by (repeatable; default: every other column)")
|
|
331
|
+
ap.add_argument("--ignore", action="append", default=[], help="column to leave out of segments (repeatable)")
|
|
332
|
+
ap.add_argument("-o", "--output", type=Path, help="write the markdown report here instead of stdout")
|
|
333
|
+
ap.add_argument("--json", type=Path, help="also write the counts as JSON to this path")
|
|
334
|
+
a = ap.parse_args(argv)
|
|
335
|
+
|
|
336
|
+
ignore = DEFAULT_IGNORE | {c.lower() for c in a.ignore}
|
|
337
|
+
rows, segs, warnings = load(a.csv, a.person_col, ignore, a.segment)
|
|
338
|
+
if not rows:
|
|
339
|
+
die(f"{a.csv} has no feedback rows")
|
|
340
|
+
label = a.person_label or ("person" if a.person_col == "person_id" else a.person_col.replace("_id", "").replace("_", " "))
|
|
341
|
+
summary, themes = analyse(rows, segs)
|
|
342
|
+
report = render(summary, themes, label, warnings)
|
|
343
|
+
if a.output:
|
|
344
|
+
a.output.write_text(report, encoding="utf-8")
|
|
345
|
+
print(f"wrote {a.output}")
|
|
346
|
+
else:
|
|
347
|
+
sys.stdout.write(report)
|
|
348
|
+
if a.json:
|
|
349
|
+
a.json.write_text(json.dumps({"summary": summary, "themes": themes, "warnings": warnings}, indent=2),
|
|
350
|
+
encoding="utf-8")
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
sys.exit(main())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
item_id,person_id,source,plan,region,themes,severity
|
|
2
|
+
T-101,A-17,tickets,Pro,UK,card-payment-fails,S1
|
|
3
|
+
T-102,A-17,tickets,Pro,UK,card-payment-fails,S1
|
|
4
|
+
N-04,,nps,Growth,,+reminders;logo-blurry:S4,
|
|
5
|
+
I-2,A-30,interview,Pro,US,rate-change-manual;+reminders,S2
|
|
6
|
+
T-108,A-41,tickets,Starter,US,,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# {What customers are telling us about {product}, {period}}
|
|
2
|
+
|
|
3
|
+
{Headline, 2–4 sentences: what to act on first and why. If the data contradicts the premise in
|
|
4
|
+
the request, say so here with the count that shows it. Name the counting unit once:
|
|
5
|
+
"Counts are distinct accounts, not tickets."}
|
|
6
|
+
|
|
7
|
+
## Themes, ranked
|
|
8
|
+
|
|
9
|
+
| # | Problem | Who | Severity | Sources |
|
|
10
|
+
|---|---|---|---|---|
|
|
11
|
+
| 1 | {Who hits what, and what it costs them} | {N accounts (M mentions); segment, or "only {segment}"} | {Blocker / Major / Minor / Cosmetic — one-line reason} | {tickets, interviews…} |
|
|
12
|
+
|
|
13
|
+
Ranked by severity, then by distinct people. {One sentence where this differs from volume order.}
|
|
14
|
+
|
|
15
|
+
## Evidence
|
|
16
|
+
|
|
17
|
+
### 1. {Problem statement}
|
|
18
|
+
- **Who:** {N accounts: segment breakdown. Repeat filing, if any.}
|
|
19
|
+
- **What it costs them:** {from the items, not inferred}
|
|
20
|
+
- **In their words:** "{verbatim}" ({ID}); "{verbatim}" ({ID})
|
|
21
|
+
- **What they asked for:** "{request, verbatim}" ({ID})
|
|
22
|
+
- **What would change this:** {the counter-evidence or the gap}
|
|
23
|
+
|
|
24
|
+
## Requests with no stated need
|
|
25
|
+
- {"Add dark mode" — N comments (IDs), no reason given. Asked about below.}
|
|
26
|
+
|
|
27
|
+
## What not to break
|
|
28
|
+
- {What people value, N people, "{verbatim}" ({ID})}
|
|
29
|
+
|
|
30
|
+
## Where the sources disagree
|
|
31
|
+
- {Source A says X (N); source B says Y (N). What would resolve it.}
|
|
32
|
+
|
|
33
|
+
## What this can't tell us
|
|
34
|
+
- **{Source}:** {who is in it}; {the conclusion it can't support}.
|
|
35
|
+
- {No share of the customer base: these are self-selected samples.}
|
|
36
|
+
|
|
37
|
+
## Questions for the next interviews
|
|
38
|
+
1. {Open, past-behaviour question} — tests theme {#}
|
|
39
|
+
2. {A question that could disconfirm theme 1}
|
|
40
|
+
|
|
41
|
+
{Next step, one plain sentence: e.g. "Next: turn themes 1 and 2 into a brief with the open
|
|
42
|
+
questions as risks."}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research
|
|
3
|
+
description: Take a question that turns on why something happened to an answer the evidence can carry: a plan tied to the decision it serves, a synthesis of the sources with every claim cited, and rival explanations ranked by how little evidence contradicts them, with a gate that sends the work back for the observation that would tell the leaders apart. Use when the user says "research why…", "find out what's causing…", "run the research loop", or needs a decision-ready answer on which explanation the evidence supports.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# research
|
|
7
|
+
|
|
8
|
+
One question, from scoping to an answer someone can decide on. The three skills each work
|
|
9
|
+
alone; in order, each one constrains the next. The plan fixes what would change the decision
|
|
10
|
+
before anyone reads a source, so the findings can't be read to fit. The synthesis cites every
|
|
11
|
+
claim, so the comparison rates only evidence that exists. The gate then asks whether that
|
|
12
|
+
evidence separates the explanations, which none of the three can judge about its own output.
|
|
13
|
+
|
|
14
|
+
## Stages
|
|
15
|
+
|
|
16
|
+
| # | Stage | Type | Skills | Gate |
|
|
17
|
+
|---|---|---|---|---|
|
|
18
|
+
| 1 | `plan` | generate | [`research-plan`](../../skills/research-plan/SKILL.md) | — |
|
|
19
|
+
| 2 | `synthesise` | generate | [`source-synthesis`](../../skills/source-synthesis/SKILL.md) | — |
|
|
20
|
+
| 3 | `compare` | verify | [`hypothesis-comparison`](../../skills/hypothesis-comparison/SKILL.md) | **G7** review |
|
|
21
|
+
|
|
22
|
+
## How to run this loop
|
|
23
|
+
|
|
24
|
+
1. **Plan before reading.** Run `research-plan` on the question. It asks what decision the
|
|
25
|
+
research serves, who makes it and by when; don't skip that. Save the plan as `plan.md`.
|
|
26
|
+
Carry three things from it into every later stage: the decision, the findings that would
|
|
27
|
+
change it, and the stopping rule.
|
|
28
|
+
2. **Gather the sources the plan names.** The person supplies them, or you collect them under
|
|
29
|
+
the plan's inclusion and exclusion criteria with the search strings it wrote. A source the
|
|
30
|
+
criteria exclude stays out, however convenient. List each as `S1: …` in `sources.md`.
|
|
31
|
+
3. **Synthesise, cited.** Run `source-synthesis` in its cited form (an evidence summary with
|
|
32
|
+
`[S1:p4]` citations), on the plan's question. It counts a source that repeats another once,
|
|
33
|
+
and its citation check runs before you hand anything on. Save it as `synthesis.md`.
|
|
34
|
+
4. **Compare the explanations.** Run `hypothesis-comparison` with the observation from the
|
|
35
|
+
plan as the thing to explain and the synthesis's findings as the evidence, keeping their
|
|
36
|
+
citations. Every evidence row cites a source from `sources.md` or is marked
|
|
37
|
+
`[assumption]`. The comparison names the leader, the diagnostic evidence, the linchpins and
|
|
38
|
+
the next observation that would tell the top two apart.
|
|
39
|
+
5. **Judge the evidence at G7.** Ask four questions, then choose the verdict below:
|
|
40
|
+
- Does at least one diagnostic row separate the leader from the runner-up?
|
|
41
|
+
- Does any linchpin rest on a single source or an `[assumption]`?
|
|
42
|
+
- Is the plan's stopping rule met?
|
|
43
|
+
- Would the finding move the decision past a threshold the plan set?
|
|
44
|
+
6. **On `REVISE`, get the observation, not more of the same.** Return to `synthesise` with the
|
|
45
|
+
one observation the comparison named as discriminating. Add it to `sources.md` as a new
|
|
46
|
+
source, re-synthesise, and compare again. More sources that the top two both predict
|
|
47
|
+
equally add nothing. Each return counts against the cap. When only a person can get the
|
|
48
|
+
observation (a data pull, an interview, a log nobody gave you), stop here: say what to
|
|
49
|
+
get, from whom, and what each result would mean, and resume when they add it.
|
|
50
|
+
7. **Hand over the answer, then the verdict.** Lead with the explanation the evidence favours
|
|
51
|
+
and its confidence, then what that means for the decision named in the plan, then the
|
|
52
|
+
open conditions. The matrix and the synthesis go underneath, for anyone who wants to
|
|
53
|
+
check. Close with the gate on its own line, `G7: <VERDICT>`, and one sentence of reasons.
|
|
54
|
+
The skills keep their own terms out of a reply; the loop's verdict is the exception,
|
|
55
|
+
because the run log and the next round both read it.
|
|
56
|
+
|
|
57
|
+
## Verdicts
|
|
58
|
+
|
|
59
|
+
Defined in [`contracts/terminals.json`](../../../../contracts/terminals.json). G7 emits:
|
|
60
|
+
|
|
61
|
+
- `PROCEED`: diagnostic evidence separates the leader, no linchpin rests on one source or an
|
|
62
|
+
assumption, and the stopping rule is met.
|
|
63
|
+
- `PROCEED WITH CONDITIONS`: the leader is clear, but a named linchpin is unchecked or a
|
|
64
|
+
sub-question is still open. The conditions travel with the answer: each one says what to
|
|
65
|
+
check and how the decision changes if it comes out the other way.
|
|
66
|
+
- `REVISE`: the top two are not separated by any diagnostic row, or the leader rests on one
|
|
67
|
+
weak row. Return to `synthesise` with the discriminating observation.
|
|
68
|
+
- `RECONSIDER`: every hypothesis is contradicted by strong evidence, or the observation
|
|
69
|
+
itself turned out wrong (a tracking change, a definition change). The question needs
|
|
70
|
+
re-planning, so the loop ends here and a new run starts at `plan`.
|
|
71
|
+
- `BLOCKED`: no decision to serve, no observation to explain, or no sources.
|
|
72
|
+
|
|
73
|
+
On the cap-th `REVISE`, emit `PROCEED WITH CONDITIONS` with the leaders still tied and say
|
|
74
|
+
plainly that the evidence doesn't settle it: the decision will be made under that
|
|
75
|
+
uncertainty, and the discriminating observation is the first thing to get.
|
|
76
|
+
|
|
77
|
+
## Non-interactive runs
|
|
78
|
+
|
|
79
|
+
With no person to name the decision, `research-plan` either infers it as an `[assumption]` or
|
|
80
|
+
emits `BLOCKED: need the decision this research informs, who makes it, and by when`. Carry
|
|
81
|
+
that `BLOCKED` through as the loop's verdict. Run no further stages on an invented decision:
|
|
82
|
+
a stopping rule set against a guessed decision stops at the wrong place. With no sources and
|
|
83
|
+
nothing to collect them from, emit `BLOCKED: need the sources, or access to collect them
|
|
84
|
+
under the plan's criteria`.
|
|
85
|
+
|
|
86
|
+
## When a stage's skill is not installed
|
|
87
|
+
|
|
88
|
+
Name the missing skill and skilldrop as its source, do the minimal inline version of that
|
|
89
|
+
stage, and record that it ran degraded. A degraded `compare` still builds a matrix that
|
|
90
|
+
ranks by inconsistency and names its linchpins, because G7 can't be judged without them.
|
|
91
|
+
Never skip G7.
|
|
92
|
+
|
|
93
|
+
## Run log (opt-in)
|
|
94
|
+
|
|
95
|
+
Only when the environment variable `SKILLDROP_LOOP_LOG` is set to a file path: after each
|
|
96
|
+
gate's verdict, append one JSON line to that file. Nothing else is recorded, and nothing is
|
|
97
|
+
sent anywhere. When the variable isn't set, or you can't read it, skip this section.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
printf '%s\n' '{"ts":"2026-10-19T14:03:00Z","loop":"research","stage":"compare","gate":"G7","verdict":"REVISE","round":1}' >> "$SKILLDROP_LOOP_LOG"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Fields: `ts` (UTC), `loop`, `stage`, `gate` (`null` for a stage without one), `verdict` (the
|
|
104
|
+
gate's exact word), and `round` (1 for the first attempt, counting returns under the cap).
|
|
105
|
+
The log holds verdicts only: never the question, the sources or the findings.
|
|
106
|
+
`skilldrop loop-stats` summarises the file.
|
|
107
|
+
|
|
108
|
+
## Quality bar
|
|
109
|
+
|
|
110
|
+
- **What would change the decision is written before any source is read.** Thresholds set after the findings are read get set to fit them.
|
|
111
|
+
- **Every evidence row in the matrix cites a source in `sources.md` or is marked `[assumption]`.** A matrix of uncited evidence ranks a favourite theory.
|
|
112
|
+
- **G7 is judged on diagnostic evidence.** Rows that every hypothesis predicts equally are named and set aside, however many there are.
|
|
113
|
+
- **A `REVISE` brings back the one observation that discriminates.** It does not bring back more sources of the same kind.
|
|
114
|
+
- **The answer leads with the explanation and what it means for the decision.** The matrix is the working, not the reply.
|
|
115
|
+
|
|
116
|
+
## Anti-patterns to avoid
|
|
117
|
+
|
|
118
|
+
- ❌ **Synthesising before the plan exists.** The sources then decide the question, and the stopping rule is whenever the reading runs out.
|
|
119
|
+
- ❌ **Rating evidence the synthesis didn't cite.** An observation from memory or a plausible industry figure is an `[assumption]` row, weighted as one.
|
|
120
|
+
- ❌ **Counting a press release and the three articles that quote it as four sources.** The synthesis counts them once, and so does the matrix.
|
|
121
|
+
- ❌ **Passing G7 on volume.** Ten consistent rows that every rival also explains don't separate anything.
|
|
122
|
+
- ❌ **Looping past the cap in search of certainty.** At the cap, say that the evidence doesn't settle it and name the next check.
|
|
123
|
+
- ❌ **Re-planning inside the loop after `RECONSIDER`.** A new question is a new run, with its own plan and stopping rule.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"query": "research why our trial-to-paid conversion dropped in September", "should_trigger": true},
|
|
3
|
+
{"query": "run the research loop on what's causing the rise in support tickets", "should_trigger": true},
|
|
4
|
+
{"query": "find out which explanation the evidence supports for the churn spike, so I can decide what to fix", "should_trigger": true},
|
|
5
|
+
{"query": "I need a decision-ready answer on why the pilot underperformed", "should_trigger": true},
|
|
6
|
+
{"query": "summarise these three papers for me", "should_trigger": false},
|
|
7
|
+
{"query": "help me scope a research plan for entering the German market", "should_trigger": false},
|
|
8
|
+
{"query": "build an ACH matrix from this evidence list", "should_trigger": false}
|
|
9
|
+
]
|