research-lens-mcp 2.0.0 → 2.3.0
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/README.md +50 -2
- package/build/index.js +437 -44
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -45,6 +45,18 @@ Research Lens gives the assistant direct, structured access to your repository.
|
|
|
45
45
|
| "Did the redesign work?" | `compare_rounds` | Sentiment, completion and SUS, round over round |
|
|
46
46
|
| "Draft a finding on onboarding" | `draft_findings` | Evidence with citations, plus what the data does not support |
|
|
47
47
|
|
|
48
|
+
Teams shipping on a release schedule, and anyone whose decisions have to survive an audit, get five more:
|
|
49
|
+
|
|
50
|
+
| Ask | Tool | You get |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| "What's still open before we ship?" | `release_readiness` | Issues by the severity you tagged, participants affected, quotes behind each |
|
|
53
|
+
| "Did it improve since last release?" | `track_metric` | Completion and SUS per study in date order, never averaged across studies |
|
|
54
|
+
| "Has anyone studied this already?" | `prior_research` | Which past studies touched a topic, when, with how many participants |
|
|
55
|
+
| "Why did we make that change?" | `evidence_trail` | Every verbatim quote behind one issue, with participant, study, release and date |
|
|
56
|
+
| "Whose experience are we guessing about?" | `coverage_gaps` | Segments, regions and themes resting on too few people |
|
|
57
|
+
|
|
58
|
+
`release_readiness` ends with what it is *not*: not a ship decision, not a severity judgment, not frequency in the wild. `coverage_gaps` ends by naming the gap no tool can show you, the people your recruitment never reached.
|
|
59
|
+
|
|
48
60
|
## The constraint
|
|
49
61
|
|
|
50
62
|
**The server retrieves and counts. It does not interpret.**
|
|
@@ -61,6 +73,35 @@ That line is enforced, not aspirational, and it shows up in four places:
|
|
|
61
73
|
|
|
62
74
|
The tools never say what a finding *means*. That judgment stays with the researcher. This is the line between AI that accelerates research and AI that quietly replaces thinking with plausible text.
|
|
63
75
|
|
|
76
|
+
## Consent, enforced at the point of query
|
|
77
|
+
|
|
78
|
+
Research platforms manage consent at the storage layer: who can log into the tool. Research
|
|
79
|
+
Lens enforces it at the **query** layer, where an AI agent actually reads. Each participant
|
|
80
|
+
carries what they agreed to, and the server obeys it.
|
|
81
|
+
|
|
82
|
+
| Consent | What happens |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `full` | Quotes reproduced normally. The default, so existing studies are unaffected. |
|
|
85
|
+
| `internal` | Quotes shown to the research team, withheld when the output is external. |
|
|
86
|
+
| `counts` | Counted in every pattern, never quoted. They agreed to be studied, not published. |
|
|
87
|
+
| `withdrawn` | Removed from every count, quote and **denominator**. |
|
|
88
|
+
|
|
89
|
+
Set `RESEARCH_LENS_AUDIENCE=external` when the answer will leave the research team.
|
|
90
|
+
|
|
91
|
+
A withheld quote is always labelled:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
MIXED (1)
|
|
95
|
+
[P07 | R1 | 02:55] [quote withheld: consented to research-team use only, and this output is external]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It is never silently dropped. "Nobody said this" and "someone said this and did not consent
|
|
99
|
+
to it leaving" must never look the same, because one is a finding and the other is a
|
|
100
|
+
compliance breach wearing a finding's clothes.
|
|
101
|
+
|
|
102
|
+
`consent_status` reports who agreed to what, who withdrew, and what is being withheld right
|
|
103
|
+
now. Consent is read from the data and never inferred.
|
|
104
|
+
|
|
64
105
|
## Install
|
|
65
106
|
|
|
66
107
|
**Claude Desktop.** Open your config:
|
|
@@ -98,7 +139,14 @@ Node 18 or newer. Nothing to clone, nothing to build.
|
|
|
98
139
|
|
|
99
140
|
## Your data
|
|
100
141
|
|
|
101
|
-
`RESEARCH_LENS_DATA` points at a folder
|
|
142
|
+
`RESEARCH_LENS_DATA` points at a folder of `.json` files, added or edited any time — the server
|
|
143
|
+
re-reads on every question, so no restart.
|
|
144
|
+
|
|
145
|
+
**Split a study across as many files as you like.** Files that declare the same `study.name` are
|
|
146
|
+
merged into one study, so one file per interview works exactly as well as one file per study. A
|
|
147
|
+
study split across files is still one study and its participants belong in one denominator.
|
|
148
|
+
Repeated participant-and-round pairs are skipped rather than double counted, and `list_studies`
|
|
149
|
+
reports which files were merged and anything it dropped.
|
|
102
150
|
|
|
103
151
|
```json
|
|
104
152
|
{
|
|
@@ -131,7 +179,7 @@ Only `id`, `round`, `segment` and the excerpt fields are required. Everything el
|
|
|
131
179
|
**From a spreadsheet.** Fill in [`template.csv`](template.csv), one row per quote, then:
|
|
132
180
|
|
|
133
181
|
```bash
|
|
134
|
-
npx research-lens-mcp-import my-study.csv "Study name"
|
|
182
|
+
npx -p research-lens-mcp research-lens-import my-study.csv "Study name"
|
|
135
183
|
```
|
|
136
184
|
|
|
137
185
|
It validates every row, names the row and the reason if something is wrong, and writes nothing until the file is clean.
|
package/build/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
|
29
29
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const SEVERITY_ORDER = ["blocker", "major", "minor"];
|
|
32
33
|
/** Where studies live. A folder of .json files, or a single .json file. */
|
|
33
34
|
const DATA_PATH = process.env.RESEARCH_LENS_DATA?.trim();
|
|
34
35
|
const SAMPLE_DIR = join(__dirname, "..", "data");
|
|
@@ -58,19 +59,20 @@ function studyFiles() {
|
|
|
58
59
|
* reads better than whatever the file happens to be called; the filename is the
|
|
59
60
|
* fallback. Collisions get a numeric suffix so two studies never share a handle.
|
|
60
61
|
*/
|
|
61
|
-
function keyFor(parsed, file
|
|
62
|
+
function keyFor(parsed, file) {
|
|
62
63
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
63
|
-
|
|
64
|
-
if (!taken.some((t) => t.key === base))
|
|
65
|
-
return base;
|
|
66
|
-
let n = 2;
|
|
67
|
-
while (taken.some((t) => t.key === `${base}-${n}`))
|
|
68
|
-
n++;
|
|
69
|
-
return `${base}-${n}`;
|
|
64
|
+
return slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
|
|
70
65
|
}
|
|
71
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Read every study fresh on each call, so edits take effect with no restart.
|
|
68
|
+
*
|
|
69
|
+
* Files that declare the same study name are MERGED into one study. A study
|
|
70
|
+
* split across one file per interview is still one study, and its participants
|
|
71
|
+
* belong in one denominator. Splitting the file should never silently split
|
|
72
|
+
* the evidence.
|
|
73
|
+
*/
|
|
72
74
|
function loadAll() {
|
|
73
|
-
const
|
|
75
|
+
const byKey = new Map();
|
|
74
76
|
for (const file of studyFiles()) {
|
|
75
77
|
let parsed;
|
|
76
78
|
try {
|
|
@@ -82,9 +84,29 @@ function loadAll() {
|
|
|
82
84
|
if (!Array.isArray(parsed?.sessions)) {
|
|
83
85
|
throw new DataError(`"${basename(file)}" has no "sessions" array.`);
|
|
84
86
|
}
|
|
85
|
-
|
|
87
|
+
const key = keyFor(parsed, file);
|
|
88
|
+
const existing = byKey.get(key);
|
|
89
|
+
const stamp = (ss) => ss.map((x) => ({ ...x, round: x.round ?? 1, _file: basename(file) }));
|
|
90
|
+
if (!existing) {
|
|
91
|
+
byKey.set(key, { ...parsed, sessions: stamp(parsed.sessions), key, files: [basename(file)], duplicates: [] });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Merge, but never count the same participant-round twice.
|
|
95
|
+
const seen = new Set(existing.sessions.map((s) => `${s.id}|${s.round}`));
|
|
96
|
+
for (const sess of parsed.sessions) {
|
|
97
|
+
const sig = `${sess.id}|${sess.round}`;
|
|
98
|
+
if (seen.has(sig)) {
|
|
99
|
+
existing.duplicates.push(`${sess.id} round ${sess.round} (${basename(file)})`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seen.add(sig);
|
|
103
|
+
existing.sessions.push({ ...sess, round: sess.round ?? 1, _file: basename(file) });
|
|
104
|
+
}
|
|
105
|
+
existing.files.push(basename(file));
|
|
106
|
+
// Keep whichever study block carries the most context.
|
|
107
|
+
existing.study = { ...parsed.study, ...existing.study };
|
|
86
108
|
}
|
|
87
|
-
return
|
|
109
|
+
return [...byKey.values()];
|
|
88
110
|
}
|
|
89
111
|
/**
|
|
90
112
|
* Pick the studies a call applies to. With no name given, every study is used,
|
|
@@ -105,19 +127,86 @@ function pick(all, name) {
|
|
|
105
127
|
}
|
|
106
128
|
/** Flatten every excerpt with its session and study context attached. */
|
|
107
129
|
function allExcerpts(studies) {
|
|
108
|
-
return studies.flatMap((st) => st.sessions.flatMap((s) => s.excerpts.map((e) => ({
|
|
130
|
+
return studies.flatMap((st) => active(st.sessions).flatMap((s) => s.excerpts.map((e) => ({
|
|
109
131
|
...e,
|
|
110
132
|
participant: s.id,
|
|
111
133
|
round: s.round,
|
|
112
134
|
segment: s.segment,
|
|
113
135
|
task: s.task,
|
|
114
136
|
study: st.key,
|
|
137
|
+
consent: consentOf(s),
|
|
138
|
+
// An explicit source always shows. The filename only helps when a study
|
|
139
|
+
// was assembled from several files, so it stays hidden otherwise.
|
|
140
|
+
source: s.source ?? (st.files.length > 1 ? s._file ?? "" : ""),
|
|
115
141
|
}))));
|
|
116
142
|
}
|
|
117
|
-
const sessionsOf = (studies) => studies.flatMap((s) => s.sessions);
|
|
143
|
+
const sessionsOf = (studies) => studies.flatMap((s) => active(s.sessions));
|
|
144
|
+
const norm = (s) => String(s ?? "").trim().toLowerCase();
|
|
145
|
+
/**
|
|
146
|
+
* Who is asking. An MCP client is a pipe to wherever the answer ends up, so the
|
|
147
|
+
* operator declares the audience once, the way an API caller declares its scope.
|
|
148
|
+
* Defaults to the research team, the most permissive setting, because an
|
|
149
|
+
* existing study with no consent field should keep working unchanged.
|
|
150
|
+
*/
|
|
151
|
+
const AUDIENCE = norm(process.env.RESEARCH_LENS_AUDIENCE) === "external" ? "external" : "team";
|
|
152
|
+
const consentOf = (s) => s.consent ?? "full";
|
|
153
|
+
/** Withdrawn participants are removed from everything, including denominators. */
|
|
154
|
+
const active = (xs) => xs.filter((x) => consentOf(x) !== "withdrawn");
|
|
155
|
+
/** May this participant's words be reproduced, for this audience? */
|
|
156
|
+
function mayQuote(consent) {
|
|
157
|
+
if (consent === "withdrawn" || consent === "counts")
|
|
158
|
+
return false;
|
|
159
|
+
if (consent === "internal")
|
|
160
|
+
return AUDIENCE === "team";
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Renders a quote, or says plainly that it is being withheld and why. Never
|
|
165
|
+
* silently omits: a reader must be able to tell the difference between
|
|
166
|
+
* "nobody said this" and "someone said this and did not consent to it leaving".
|
|
167
|
+
*/
|
|
168
|
+
function quoteOrWithheld(h) {
|
|
169
|
+
if (mayQuote(h.consent))
|
|
170
|
+
return `"${h.quote}"`;
|
|
171
|
+
return h.consent === "counts"
|
|
172
|
+
? `[quote withheld: participant consented to be counted, not quoted]`
|
|
173
|
+
: `[quote withheld: consented to research-team use only, and this output is external]`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Themes are matched case-insensitively. An assistant will naturally write
|
|
177
|
+
* "Trust" for a theme filed as "trust", and returning "no quotes" for a
|
|
178
|
+
* capital letter would read as an absence of evidence, which is the most
|
|
179
|
+
* dangerous wrong answer this server could give.
|
|
180
|
+
*/
|
|
181
|
+
const themeMatches = (rowTheme, wanted) => norm(rowTheme) === norm(wanted);
|
|
182
|
+
/** When a theme finds nothing, say what does exist rather than just "none". */
|
|
183
|
+
function themeSuggestion(rows, wanted) {
|
|
184
|
+
const all = [...new Set(rows.map((r) => r.theme))];
|
|
185
|
+
if (!all.length)
|
|
186
|
+
return "";
|
|
187
|
+
const q = norm(wanted);
|
|
188
|
+
const near = all.filter((t) => norm(t).includes(q) || q.includes(norm(t)));
|
|
189
|
+
return near.length
|
|
190
|
+
? ` Did you mean: ${near.join(", ")}?`
|
|
191
|
+
: ` Themes in scope: ${all.join(", ")}.`;
|
|
192
|
+
}
|
|
193
|
+
/** Optional segment filter, case-insensitive, shared by the retrieval tools. */
|
|
194
|
+
function applySegment(rows, segment) {
|
|
195
|
+
if (!segment)
|
|
196
|
+
return { rows, label: "" };
|
|
197
|
+
const q = norm(segment);
|
|
198
|
+
const hit = rows.filter((r) => norm(r.segment) === q);
|
|
199
|
+
if (hit.length)
|
|
200
|
+
return { rows: hit, label: `, segment "${segment}"` };
|
|
201
|
+
const loose = rows.filter((r) => norm(r.segment).includes(q));
|
|
202
|
+
return { rows: loose, label: `, segment "${segment}"` };
|
|
203
|
+
}
|
|
118
204
|
/** Prefix a citation with the study only when more than one is in scope. */
|
|
119
|
-
const cite = (h, multi) => `${multi ? h.study + " | " : ""}${h.participant}
|
|
120
|
-
|
|
205
|
+
const cite = (h, multi, showRound = true) => `${multi ? h.study + " | " : ""}${h.participant}` +
|
|
206
|
+
(showRound && h.round != null ? ` | R${h.round}` : "") +
|
|
207
|
+
` | ${h.t}` +
|
|
208
|
+
(h.source ? ` | ${h.source}` : "");
|
|
209
|
+
const server = new Server({ name: "research-lens", version: "2.3.0" }, { capabilities: { tools: {} } });
|
|
121
210
|
const STUDY_ARG = {
|
|
122
211
|
study: {
|
|
123
212
|
type: "string",
|
|
@@ -138,6 +227,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
138
227
|
type: "object",
|
|
139
228
|
properties: {
|
|
140
229
|
query: { type: "string", description: "Words or phrase to find." },
|
|
230
|
+
segment: { type: "string", description: "Optional. Restrict to one user segment." },
|
|
141
231
|
...STUDY_ARG,
|
|
142
232
|
},
|
|
143
233
|
required: ["query"],
|
|
@@ -149,8 +239,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
149
239
|
inputSchema: {
|
|
150
240
|
type: "object",
|
|
151
241
|
properties: {
|
|
152
|
-
theme: { type: "string", description: "e.g. trust, onboarding, navigation" },
|
|
242
|
+
theme: { type: "string", description: "e.g. trust, onboarding, navigation. Case-insensitive." },
|
|
153
243
|
round: { type: "number", description: "Optional: restrict to one research round." },
|
|
244
|
+
segment: { type: "string", description: "Optional. Restrict to one user segment." },
|
|
154
245
|
...STUDY_ARG,
|
|
155
246
|
},
|
|
156
247
|
required: ["theme"],
|
|
@@ -179,6 +270,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
179
270
|
required: ["theme"],
|
|
180
271
|
},
|
|
181
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: "release_readiness",
|
|
275
|
+
description: "Issues found in research, grouped by the severity the researcher tagged, with how many participants hit each and the quotes behind them. Answers 'what is still open before we ship'. Ends with what the evidence does not cover.",
|
|
276
|
+
inputSchema: { type: "object", properties: { ...STUDY_ARG } },
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "track_metric",
|
|
280
|
+
description: "One measure across studies in date order: task completion or mean SUS, optionally split by segment. Answers 'did it improve since last release'. Reports each study separately and never averages across them.",
|
|
281
|
+
inputSchema: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
metric: { type: "string", description: "completion or sus. Defaults to both." },
|
|
285
|
+
segment: { type: "string", description: "Optional. Restrict to one user segment." },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "prior_research",
|
|
291
|
+
description: "Which past studies already touched a topic, when, and with how many participants. Call this before commissioning new research so a question that has already been answered is not paid for twice.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: { topic: { type: "string", description: "A theme, issue or phrase." } },
|
|
295
|
+
required: ["topic"],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "evidence_trail",
|
|
300
|
+
description: "Every piece of evidence behind one named issue, across all studies, with participant, study, release and date. Use when someone asks months later why a change was made, or when a decision has to be defensible to an auditor or regulator.",
|
|
301
|
+
inputSchema: {
|
|
302
|
+
type: "object",
|
|
303
|
+
properties: { issue: { type: "string", description: "Issue name, or a theme." } },
|
|
304
|
+
required: ["issue"],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "consent_status",
|
|
309
|
+
description: "What each participant agreed to, and what is therefore withheld. Shows the current audience setting, who has withdrawn, and whose quotes cannot be reproduced. Call this before putting quotes in anything that leaves the research team.",
|
|
310
|
+
inputSchema: { type: "object", properties: { ...STUDY_ARG } },
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "coverage_gaps",
|
|
314
|
+
description: "Who has NOT been researched: segments and regions with thin or no evidence, and themes resting on very few participants. Answers 'whose experience are we guessing about'. The most important tool for anyone about to generalise a finding.",
|
|
315
|
+
inputSchema: { type: "object", properties: { ...STUDY_ARG } },
|
|
316
|
+
},
|
|
182
317
|
],
|
|
183
318
|
}));
|
|
184
319
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
@@ -199,16 +334,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
199
334
|
const rows = allExcerpts(studies);
|
|
200
335
|
const sessions = sessionsOf(studies);
|
|
201
336
|
const multi = studies.length > 1;
|
|
337
|
+
// Rounds are only worth printing when a study actually ran more than one.
|
|
338
|
+
const showRound = new Set(sessions.map((x) => x.round ?? 1)).size > 1;
|
|
202
339
|
const scope = args.study ? ` in "${args.study}"` : multi ? ` across ${studies.length} studies` : "";
|
|
203
340
|
if (name === "list_studies") {
|
|
204
341
|
const lines = all.map((st) => {
|
|
205
342
|
const people = new Set(st.sessions.map((s) => s.id));
|
|
206
|
-
const rounds = [...new Set(st.sessions.map((s) => s.round))].sort();
|
|
343
|
+
const rounds = [...new Set(st.sessions.map((s) => s.round ?? 1))].sort();
|
|
207
344
|
const segments = [...new Set(st.sessions.map((s) => s.segment))];
|
|
208
345
|
const themes = [...new Set(st.sessions.flatMap((s) => s.excerpts.map((e) => e.theme)))];
|
|
209
346
|
const quotes = st.sessions.reduce((a, s) => a + s.excerpts.length, 0);
|
|
210
347
|
return (`${st.key}\n` +
|
|
211
348
|
` name: ${st.study?.name ?? "(unnamed)"}\n` +
|
|
349
|
+
(st.files.length > 1 ? ` merged from: ${st.files.length} files (${st.files.join(", ")})\n` : "") +
|
|
350
|
+
(st.duplicates.length
|
|
351
|
+
? ` SKIPPED as duplicates: ${st.duplicates.join("; ")}\n`
|
|
352
|
+
: "") +
|
|
212
353
|
` participants: ${people.size} sessions: ${st.sessions.length} quotes: ${quotes}\n` +
|
|
213
354
|
` rounds: ${rounds.join(", ") || "none"}\n` +
|
|
214
355
|
` segments: ${segments.join(", ") || "none"}\n` +
|
|
@@ -222,28 +363,42 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
222
363
|
}
|
|
223
364
|
if (name === "search_transcripts") {
|
|
224
365
|
const q = String(args.query ?? "").toLowerCase();
|
|
225
|
-
const
|
|
366
|
+
const seg = applySegment(rows, args.segment);
|
|
367
|
+
const hits = seg.rows.filter((r) => r.quote.toLowerCase().includes(q) || r.theme.toLowerCase().includes(q));
|
|
226
368
|
if (!hits.length)
|
|
227
|
-
return out(`No excerpts match "${args.query}"${scope}.`);
|
|
228
|
-
return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}:\n\n` +
|
|
369
|
+
return out(`No excerpts match "${args.query}"${scope}${seg.label}.`);
|
|
370
|
+
return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}${seg.label}:\n\n` +
|
|
229
371
|
hits
|
|
230
|
-
.map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n
|
|
372
|
+
.map((h) => `[${cite(h, multi, showRound)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
|
|
231
373
|
.join("\n\n"));
|
|
232
374
|
}
|
|
233
375
|
if (name === "quotes_by_theme") {
|
|
234
|
-
|
|
376
|
+
const seg = applySegment(rows, args.segment);
|
|
377
|
+
let hits = seg.rows.filter((r) => themeMatches(r.theme, args.theme));
|
|
235
378
|
if (typeof args.round === "number")
|
|
236
|
-
hits = hits.filter((r) => r.round === args.round);
|
|
237
|
-
if (!hits.length)
|
|
238
|
-
|
|
379
|
+
hits = hits.filter((r) => (r.round ?? 1) === args.round);
|
|
380
|
+
if (!hits.length) {
|
|
381
|
+
// Distinguish "that theme does not exist" from "it exists, just not in
|
|
382
|
+
// that segment". Conflating them reads as an absence of evidence.
|
|
383
|
+
const themeExists = rows.some((r) => themeMatches(r.theme, args.theme));
|
|
384
|
+
if (themeExists && args.segment) {
|
|
385
|
+
const segs = [
|
|
386
|
+
...new Set(rows.filter((r) => themeMatches(r.theme, args.theme)).map((r) => r.segment)),
|
|
387
|
+
];
|
|
388
|
+
return out(`"${args.theme}" was raised${scope}, but not by any participant in segment "${args.segment}".\n` +
|
|
389
|
+
`Segments that did raise it: ${segs.join(", ")}.\n` +
|
|
390
|
+
`That the segment did not raise it is not evidence they do not care. They may not have been asked.`);
|
|
391
|
+
}
|
|
392
|
+
return out(`No quotes tagged "${args.theme}"${scope}${seg.label}.` + themeSuggestion(rows, args.theme));
|
|
393
|
+
}
|
|
239
394
|
const groups = { positive: [], negative: [], mixed: [] };
|
|
240
395
|
hits.forEach((h) => groups[h.sentiment]?.push(h));
|
|
241
396
|
const body = Object.entries(groups)
|
|
242
397
|
.filter(([, v]) => v.length)
|
|
243
398
|
.map(([k, v]) => `${k.toUpperCase()} (${v.length})\n` +
|
|
244
|
-
v.map((h) => ` [${cite(h, multi)}]
|
|
399
|
+
v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n"))
|
|
245
400
|
.join("\n\n");
|
|
246
|
-
return out(`Theme "${args.theme}"${scope} — ${hits.length} excerpt(s)\n\n${body}`);
|
|
401
|
+
return out(`Theme "${args.theme}"${scope}${seg.label} — ${hits.length} excerpt(s)\n\n${body}`);
|
|
247
402
|
}
|
|
248
403
|
if (name === "pattern_frequency") {
|
|
249
404
|
// Counts are never pooled across studies. Different studies have different
|
|
@@ -251,7 +406,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
251
406
|
// would be a number that looks like evidence and is not one.
|
|
252
407
|
const perStudy = (st) => {
|
|
253
408
|
const rws = allExcerpts([st]);
|
|
254
|
-
|
|
409
|
+
// active(), not st.sessions: a withdrawn participant must not sit in a
|
|
410
|
+
// denominator. Leaving them in silently understates every percentage.
|
|
411
|
+
const live = active(st.sessions);
|
|
412
|
+
const total = new Set(live.map((s) => s.id)).size;
|
|
255
413
|
if (!total)
|
|
256
414
|
return `${st.key}: no sessions.`;
|
|
257
415
|
const byTheme = {};
|
|
@@ -268,7 +426,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
268
426
|
.join(", ");
|
|
269
427
|
return ` ${theme}: ${set.size}/${total} participants (${seg})`;
|
|
270
428
|
});
|
|
271
|
-
return `${st.key} — ${
|
|
429
|
+
return `${st.key} — ${live.length} sessions\n${lines.join("\n")}`;
|
|
272
430
|
};
|
|
273
431
|
if (!sessions.length)
|
|
274
432
|
return out(`No sessions${scope}.`);
|
|
@@ -288,13 +446,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
288
446
|
return out(`Rounds can only be compared within one study. Name one of: ${all.map((s) => s.key).join(", ")}.`);
|
|
289
447
|
}
|
|
290
448
|
const theme = String(args.theme);
|
|
291
|
-
|
|
449
|
+
// round is optional in the data; loadAll defaults it, but narrow for the type
|
|
450
|
+
const rounds = [...new Set(sessions.map((s) => s.round ?? 1))].sort((a, b) => a - b);
|
|
292
451
|
if (rounds.length < 2) {
|
|
293
|
-
return out(
|
|
452
|
+
return out(rounds.length === 0
|
|
453
|
+
? `No sessions${scope}, so there is nothing to compare.`
|
|
454
|
+
: `This study has a single round${scope}, so there is nothing to compare. ` +
|
|
455
|
+
`compare_rounds is for studies run before and after a design change.`);
|
|
294
456
|
}
|
|
295
457
|
const summarise = (round) => {
|
|
296
|
-
const hits = rows.filter((r) => r.theme === theme && r.round === round);
|
|
297
|
-
const ss = sessions.filter((s) => s.round === round);
|
|
458
|
+
const hits = rows.filter((r) => r.theme === theme && (r.round ?? 1) === round);
|
|
459
|
+
const ss = sessions.filter((s) => (s.round ?? 1) === round);
|
|
298
460
|
const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
|
|
299
461
|
return {
|
|
300
462
|
pos: hits.filter((h) => h.sentiment === "positive").length,
|
|
@@ -304,17 +466,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
304
466
|
sus: scored.length ? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length) : null,
|
|
305
467
|
};
|
|
306
468
|
};
|
|
307
|
-
const
|
|
308
|
-
const
|
|
469
|
+
const perRound = rounds.map(summarise);
|
|
470
|
+
const first = perRound[0];
|
|
471
|
+
const last = perRound[perRound.length - 1];
|
|
309
472
|
const susLine = (r) => (r.sus === null ? "not recorded" : String(r.sus));
|
|
310
473
|
const susDelta = first.sus !== null && last.sus !== null
|
|
311
474
|
? `SUS ${last.sus - first.sus >= 0 ? "+" : ""}${last.sus - first.sus}`
|
|
312
475
|
: "SUS not comparable";
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
`
|
|
317
|
-
|
|
476
|
+
// Every round is listed. With three or more, reporting only the first and
|
|
477
|
+
// last would silently hide a regression in the middle.
|
|
478
|
+
const header = rounds.length === 2
|
|
479
|
+
? `round ${rounds[0]} vs round ${rounds[1]}`
|
|
480
|
+
: `rounds ${rounds.join(", ")}`;
|
|
481
|
+
const body = rounds
|
|
482
|
+
.map((rd, i) => {
|
|
483
|
+
const r = perRound[i];
|
|
484
|
+
return `Round ${rd}: ${r.pos} positive / ${r.neg} negative | completed ${r.done}/${r.n} | mean SUS ${susLine(r)}`;
|
|
485
|
+
})
|
|
486
|
+
.join("\n");
|
|
487
|
+
const totalSessions = perRound.reduce((a, r) => a + r.n, 0);
|
|
488
|
+
return out(`Theme "${theme}"${scope}, ${header}\n\n` +
|
|
489
|
+
body +
|
|
490
|
+
`\n\nDirection: sentiment ${last.pos - first.pos >= 0 ? "improved" : "declined"}, ${susDelta}.\n` +
|
|
491
|
+
`Caution: ${totalSessions} sessions is a small n. Treat as directional, not significant.` +
|
|
492
|
+
(rounds.length > 2
|
|
493
|
+
? `\nDirection compares round ${rounds[0]} with round ${rounds[rounds.length - 1]} only. Read the middle rounds above before concluding it improved throughout.`
|
|
494
|
+
: ""));
|
|
318
495
|
}
|
|
319
496
|
if (name === "draft_findings") {
|
|
320
497
|
// A finding belongs to a study. Drafting one over pooled studies would put a
|
|
@@ -323,9 +500,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
323
500
|
return out(`A finding is drafted from one study at a time, so the participant count means something. Name one of: ${all.map((s) => s.key).join(", ")}.`);
|
|
324
501
|
}
|
|
325
502
|
const theme = String(args.theme);
|
|
326
|
-
const hits = rows.filter((r) => r.theme
|
|
503
|
+
const hits = rows.filter((r) => themeMatches(r.theme, theme));
|
|
327
504
|
if (!hits.length)
|
|
328
|
-
return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.`
|
|
505
|
+
return out(`No evidence tagged "${theme}"${scope}, so no finding can be drafted.` +
|
|
506
|
+
themeSuggestion(rows, theme));
|
|
329
507
|
const people = new Set(hits.map((h) => h.participant));
|
|
330
508
|
const total = new Set(sessions.map((s) => s.id)).size;
|
|
331
509
|
const neg = hits.filter((h) => h.sentiment === "negative");
|
|
@@ -333,7 +511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
333
511
|
const mix = hits.filter((h) => h.sentiment === "mixed");
|
|
334
512
|
const block = (label, v) => v.length
|
|
335
513
|
? `${label} (${v.length}):\n` +
|
|
336
|
-
v.map((h) => ` [${cite(h, multi)}]
|
|
514
|
+
v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n") +
|
|
337
515
|
"\n"
|
|
338
516
|
: "";
|
|
339
517
|
return out(`DRAFT FINDING — ${theme}${scope}\n\n` +
|
|
@@ -350,6 +528,221 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
350
528
|
` Any causal claim. This is correlation in a small sample.\n\n` +
|
|
351
529
|
`The researcher writes the recommendation. This tool supplies evidence and its limits.`);
|
|
352
530
|
}
|
|
531
|
+
// ---------------------------------------------------------------------
|
|
532
|
+
// Tools for teams who have to make a release decision, not just read a
|
|
533
|
+
// report. They still only retrieve and count. What they add is the honest
|
|
534
|
+
// other half: what the evidence does NOT cover.
|
|
535
|
+
// ---------------------------------------------------------------------
|
|
536
|
+
const studyLabel = (st) => {
|
|
537
|
+
const bits = [st.study?.product, st.study?.release, st.study?.date].filter(Boolean);
|
|
538
|
+
return bits.length ? `${st.key} (${bits.join(", ")})` : st.key;
|
|
539
|
+
};
|
|
540
|
+
if (name === "release_readiness") {
|
|
541
|
+
if (!rows.length)
|
|
542
|
+
return out(`No evidence${scope}.`);
|
|
543
|
+
const people = new Set(sessions.map((s) => s.id)).size;
|
|
544
|
+
const tagged = rows.filter((r) => r.severity);
|
|
545
|
+
if (!tagged.length) {
|
|
546
|
+
return out(`No severity tags${scope}, so this cannot report readiness.\n\n` +
|
|
547
|
+
`Add "severity": "blocker" | "major" | "minor" to the excerpts that represent problems. ` +
|
|
548
|
+
`Severity is the researcher's judgment, which is why this tool reads it rather than deciding it.`);
|
|
549
|
+
}
|
|
550
|
+
// Group by issue where tagged, otherwise fall back to the theme.
|
|
551
|
+
const byIssue = {};
|
|
552
|
+
tagged.forEach((r) => ((byIssue[r.issue || r.theme] ??= []).push(r)));
|
|
553
|
+
const sections = SEVERITY_ORDER.map((sev) => {
|
|
554
|
+
const issues = Object.entries(byIssue)
|
|
555
|
+
.filter(([, v]) => v.some((r) => r.severity === sev))
|
|
556
|
+
.map(([issue, v]) => {
|
|
557
|
+
const hits = v.filter((r) => r.severity === sev);
|
|
558
|
+
const who = new Set(hits.map((h) => h.participant));
|
|
559
|
+
const segs = [...new Set(hits.map((h) => h.segment))];
|
|
560
|
+
const quotes = hits
|
|
561
|
+
.slice(0, 3)
|
|
562
|
+
.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`)
|
|
563
|
+
.join("\n");
|
|
564
|
+
return {
|
|
565
|
+
n: who.size,
|
|
566
|
+
text: ` ${issue}: ${who.size} of ${people} participants (${segs.join(", ")})\n` +
|
|
567
|
+
quotes +
|
|
568
|
+
(hits.length > 3 ? `\n ...and ${hits.length - 3} more` : ""),
|
|
569
|
+
};
|
|
570
|
+
})
|
|
571
|
+
.sort((a, b) => b.n - a.n);
|
|
572
|
+
if (!issues.length)
|
|
573
|
+
return null;
|
|
574
|
+
const total = new Set(tagged.filter((r) => r.severity === sev).map((r) => r.participant)).size;
|
|
575
|
+
return `${sev.toUpperCase()} — ${issues.length} issue(s), ${total} participant(s) affected\n${issues
|
|
576
|
+
.map((i) => i.text)
|
|
577
|
+
.join("\n\n")}`;
|
|
578
|
+
}).filter(Boolean);
|
|
579
|
+
const segs = [...new Set(sessions.map((s) => s.segment))];
|
|
580
|
+
const regions = [...new Set(sessions.map((s) => s.region).filter(Boolean))];
|
|
581
|
+
const thin = segs.filter((sg) => sessions.filter((s) => s.segment === sg).length < 5);
|
|
582
|
+
return out(`RELEASE READINESS${scope}\n` +
|
|
583
|
+
`Evidence from ${studies.length} study/studies, ${people} participants, ${sessions.length} sessions.\n` +
|
|
584
|
+
studies.map((st) => ` ${studyLabel(st)}`).join("\n") +
|
|
585
|
+
`\n\n${sections.join("\n\n")}\n\n` +
|
|
586
|
+
`WHAT THIS IS NOT\n` +
|
|
587
|
+
` Not a ship decision. It counts participants who hit an issue; it does not weigh that against revenue, deadline or effort.\n` +
|
|
588
|
+
` Not a severity judgment. Severity is read from the researcher's tags, not inferred.\n` +
|
|
589
|
+
` Not frequency in the wild. ${people} participants in a lab is not ${people} percent of customers.\n\n` +
|
|
590
|
+
`THIN EVIDENCE, TREAT WITH CARE\n` +
|
|
591
|
+
(thin.length
|
|
592
|
+
? thin
|
|
593
|
+
.map((sg) => ` ${sg}: only ${sessions.filter((s) => s.segment === sg).length} participant(s)`)
|
|
594
|
+
.join("\n")
|
|
595
|
+
: ` Every segment has 5 or more participants.`) +
|
|
596
|
+
(regions.length ? `\n Regions covered: ${regions.join(", ")}` : `\n No region recorded on any session.`));
|
|
597
|
+
}
|
|
598
|
+
if (name === "track_metric") {
|
|
599
|
+
const want = String(args.metric ?? "").toLowerCase();
|
|
600
|
+
const seg = args.segment ? String(args.segment).toLowerCase() : null;
|
|
601
|
+
const dated = [...all].sort((a, b) => String(a.study?.date ?? "").localeCompare(String(b.study?.date ?? "")));
|
|
602
|
+
const lines = dated.map((st) => {
|
|
603
|
+
let ss = st.sessions;
|
|
604
|
+
if (seg)
|
|
605
|
+
ss = ss.filter((s) => s.segment.toLowerCase() === seg);
|
|
606
|
+
if (!ss.length)
|
|
607
|
+
return `${studyLabel(st)}: no sessions${seg ? ` for segment "${args.segment}"` : ""}`;
|
|
608
|
+
const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
|
|
609
|
+
const completion = `${ss.filter((s) => s.completed).length}/${ss.length}`;
|
|
610
|
+
const sus = scored.length
|
|
611
|
+
? Math.round(scored.reduce((a, s) => a + s.sus, 0) / scored.length)
|
|
612
|
+
: null;
|
|
613
|
+
const parts = [];
|
|
614
|
+
if (!want || want.includes("complet"))
|
|
615
|
+
parts.push(`completion ${completion}`);
|
|
616
|
+
if (!want || want.includes("sus"))
|
|
617
|
+
parts.push(`mean SUS ${sus === null ? "not recorded" : sus}`);
|
|
618
|
+
return `${studyLabel(st)}: ${parts.join(" | ")} (n=${ss.length})`;
|
|
619
|
+
});
|
|
620
|
+
return out(`Metric over time${seg ? `, segment "${args.segment}"` : ""}, oldest first\n\n` +
|
|
621
|
+
lines.join("\n") +
|
|
622
|
+
`\n\nStudies are listed separately and never averaged together. Different participants, recruitment and tasks.\n` +
|
|
623
|
+
(dated.some((st) => !st.study?.date)
|
|
624
|
+
? `Some studies have no "date" in their study block, so this order may be wrong. Add one to fix it.`
|
|
625
|
+
: `Ordered by the "date" field on each study.`));
|
|
626
|
+
}
|
|
627
|
+
if (name === "prior_research") {
|
|
628
|
+
const q = String(args.topic ?? "").toLowerCase();
|
|
629
|
+
const found = all
|
|
630
|
+
.map((st) => {
|
|
631
|
+
const rws = allExcerpts([st]).filter((r) => r.theme.toLowerCase().includes(q) ||
|
|
632
|
+
r.quote.toLowerCase().includes(q) ||
|
|
633
|
+
(r.issue ?? "").toLowerCase().includes(q));
|
|
634
|
+
return { st, rws, people: new Set(rws.map((r) => r.participant)) };
|
|
635
|
+
})
|
|
636
|
+
.filter((x) => x.rws.length);
|
|
637
|
+
if (!found.length) {
|
|
638
|
+
return out(`No existing study in this repository covers "${args.topic}".\n\n` +
|
|
639
|
+
`Searched ${all.length} study/studies: ${all.map((s) => s.key).join(", ")}.\n` +
|
|
640
|
+
`Absence here means nobody filed it, which is not the same as nobody having asked. Check outside the repository before concluding it is unresearched.`);
|
|
641
|
+
}
|
|
642
|
+
return out(`"${args.topic}" has already been researched in ${found.length} study/studies\n\n` +
|
|
643
|
+
found
|
|
644
|
+
.map((x) => `${studyLabel(x.st)}\n` +
|
|
645
|
+
` ${x.people.size} participant(s), ${x.rws.length} excerpt(s)\n` +
|
|
646
|
+
` themes: ${[...new Set(x.rws.map((r) => r.theme))].join(", ")}\n` +
|
|
647
|
+
` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true, showRound)}] ${quoteOrWithheld(x.rws[0])}`)
|
|
648
|
+
.join("\n\n") +
|
|
649
|
+
`\n\nRead these before commissioning new research on the topic.`);
|
|
650
|
+
}
|
|
651
|
+
if (name === "evidence_trail") {
|
|
652
|
+
const q = String(args.issue ?? "").toLowerCase();
|
|
653
|
+
const hits = allExcerpts(all).filter((r) => (r.issue ?? "").toLowerCase() === q || r.theme.toLowerCase() === q ||
|
|
654
|
+
(r.issue ?? "").toLowerCase().includes(q));
|
|
655
|
+
if (!hits.length) {
|
|
656
|
+
return out(`No evidence filed under "${args.issue}" in any study.`);
|
|
657
|
+
}
|
|
658
|
+
const byStudy = {};
|
|
659
|
+
hits.forEach((h) => ((byStudy[h.study] ??= []).push(h)));
|
|
660
|
+
const people = new Set(hits.map((h) => h.participant));
|
|
661
|
+
return out(`EVIDENCE TRAIL — ${args.issue}\n` +
|
|
662
|
+
`${hits.length} excerpt(s) from ${people.size} participant(s), across ${Object.keys(byStudy).length} study/studies.\n\n` +
|
|
663
|
+
Object.entries(byStudy)
|
|
664
|
+
.map(([key, v]) => {
|
|
665
|
+
const st = all.find((s) => s.key === key);
|
|
666
|
+
return (`${studyLabel(st)}\n` +
|
|
667
|
+
v
|
|
668
|
+
.map((h) => ` [${h.participant} | R${h.round} | ${h.t}] ${h.segment}` +
|
|
669
|
+
(h.severity ? ` | ${h.severity}` : "") +
|
|
670
|
+
`\n ${quoteOrWithheld(h)}`)
|
|
671
|
+
.join("\n"));
|
|
672
|
+
})
|
|
673
|
+
.join("\n\n") +
|
|
674
|
+
`\n\nEvery line above is a participant's own words with the session it came from. Nothing here is summarised or rephrased.`);
|
|
675
|
+
}
|
|
676
|
+
if (name === "consent_status") {
|
|
677
|
+
const everySession = studies.flatMap((st) => st.sessions.map((x) => ({ ...x, study: st.key })));
|
|
678
|
+
if (!everySession.length)
|
|
679
|
+
return out(`No sessions${scope}.`);
|
|
680
|
+
const group = {
|
|
681
|
+
full: [], internal: [], counts: [], withdrawn: [],
|
|
682
|
+
};
|
|
683
|
+
everySession.forEach((x) => group[consentOf(x)].push(x));
|
|
684
|
+
const line = (x) => ` ${x.id}${multi ? ` (${x.study})` : ""}${x.consent_note ? ` — ${x.consent_note}` : ""}`;
|
|
685
|
+
const section = (c, explain) => group[c].length ? `${c.toUpperCase()} (${group[c].length}) — ${explain}\n${group[c].map(line).join("\n")}` : "";
|
|
686
|
+
const withheld = everySession.filter((x) => consentOf(x) !== "withdrawn" && !mayQuote(consentOf(x))).length;
|
|
687
|
+
return out(`CONSENT${scope}\n` +
|
|
688
|
+
`Audience: ${AUDIENCE === "team" ? "research team (RESEARCH_LENS_AUDIENCE unset or \"team\")" : "external (RESEARCH_LENS_AUDIENCE=external)"}\n` +
|
|
689
|
+
`${everySession.length} participants on file, ${group.withdrawn.length} withdrawn, ` +
|
|
690
|
+
`${withheld} whose quotes are withheld at this audience setting.\n\n` +
|
|
691
|
+
[
|
|
692
|
+
section("full", "quotes may be reproduced"),
|
|
693
|
+
section("internal", `quotes shown to the research team only${AUDIENCE === "external" ? ", withheld here" : ""}`),
|
|
694
|
+
section("counts", "counted, never quoted"),
|
|
695
|
+
section("withdrawn", "removed from every count, quote and denominator"),
|
|
696
|
+
]
|
|
697
|
+
.filter(Boolean)
|
|
698
|
+
.join("\n\n") +
|
|
699
|
+
`\n\nHOW THIS IS ENFORCED\n` +
|
|
700
|
+
` Withdrawn participants are removed before anything is counted, so they do not sit in a denominator.\n` +
|
|
701
|
+
` A withheld quote is always labelled as withheld, never silently dropped. "Nobody said this" and "someone said this and did not consent to it leaving" must never look the same.\n` +
|
|
702
|
+
` Consent is read from the data. This server never infers it, and a participant with no consent field is treated as "full", which is how studies recorded before consent tracking behave.\n` +
|
|
703
|
+
` Set RESEARCH_LENS_AUDIENCE=external when the output will leave the research team.`);
|
|
704
|
+
}
|
|
705
|
+
if (name === "coverage_gaps") {
|
|
706
|
+
if (!sessions.length)
|
|
707
|
+
return out(`No sessions${scope}.`);
|
|
708
|
+
const people = new Set(sessions.map((s) => s.id)).size;
|
|
709
|
+
const bySeg = {};
|
|
710
|
+
const byRegion = {};
|
|
711
|
+
sessions.forEach((s) => {
|
|
712
|
+
(bySeg[s.segment] ??= new Set()).add(s.id);
|
|
713
|
+
if (s.region)
|
|
714
|
+
(byRegion[s.region] ??= new Set()).add(s.id);
|
|
715
|
+
});
|
|
716
|
+
const byTheme = {};
|
|
717
|
+
rows.forEach((r) => (byTheme[r.theme] ??= new Set()).add(r.participant));
|
|
718
|
+
const fragile = Object.entries(byTheme)
|
|
719
|
+
.filter(([, v]) => v.size <= 2)
|
|
720
|
+
.sort((a, b) => a[1].size - b[1].size);
|
|
721
|
+
const thinSeg = Object.entries(bySeg).filter(([, v]) => v.size < 5);
|
|
722
|
+
return out(`COVERAGE GAPS${scope}\n${people} participants across ${sessions.length} sessions.\n\n` +
|
|
723
|
+
`SEGMENTS\n` +
|
|
724
|
+
Object.entries(bySeg)
|
|
725
|
+
.sort((a, b) => b[1].size - a[1].size)
|
|
726
|
+
.map(([k, v]) => ` ${k}: ${v.size}`)
|
|
727
|
+
.join("\n") +
|
|
728
|
+
(thinSeg.length
|
|
729
|
+
? `\n Thin: ${thinSeg.map(([k, v]) => `${k} (${v.size})`).join(", ")}. A finding about these rests on very few people.`
|
|
730
|
+
: `\n No segment below 5 participants.`) +
|
|
731
|
+
`\n\nREGIONS\n` +
|
|
732
|
+
(Object.keys(byRegion).length
|
|
733
|
+
? Object.entries(byRegion)
|
|
734
|
+
.map(([k, v]) => ` ${k}: ${v.size}`)
|
|
735
|
+
.join("\n")
|
|
736
|
+
: ` No region recorded on any session, so nothing can be said about geographic coverage.`) +
|
|
737
|
+
`\n\nTHEMES RESTING ON 1 OR 2 PARTICIPANTS\n` +
|
|
738
|
+
(fragile.length
|
|
739
|
+
? fragile.map(([k, v]) => ` ${k}: ${v.size}`).join("\n") +
|
|
740
|
+
`\n These are leads, not findings. Do not put them in a readout as patterns.`
|
|
741
|
+
: ` Every theme has 3 or more participants behind it.`) +
|
|
742
|
+
`\n\nWHAT IS MISSING ENTIRELY\n` +
|
|
743
|
+
` This tool can only report on people who were recruited. Anyone your recruitment never reached is invisible here, ` +
|
|
744
|
+
`including non-users, people who gave up before becoming customers, and anyone screened out. That is usually the biggest gap and no tool can show it to you.`);
|
|
745
|
+
}
|
|
353
746
|
return out(`Unknown tool: ${name}`);
|
|
354
747
|
});
|
|
355
748
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "research-lens-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "An MCP server that lets an AI assistant query a UX research repository with participant-level citations attached. It retrieves and counts; it does not interpret.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,12 @@
|
|
|
32
32
|
"claude",
|
|
33
33
|
"ai-research",
|
|
34
34
|
"synthesis",
|
|
35
|
-
"usability-testing"
|
|
35
|
+
"usability-testing",
|
|
36
|
+
"consent",
|
|
37
|
+
"gdpr",
|
|
38
|
+
"research-ethics",
|
|
39
|
+
"irb",
|
|
40
|
+
"participant-consent"
|
|
36
41
|
],
|
|
37
42
|
"license": "MIT",
|
|
38
43
|
"author": "Siddhi Manche",
|