skilldrop-cli 0.16.29 → 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/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/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
|
@@ -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."}
|