research-lens-mcp 2.2.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 CHANGED
@@ -139,7 +139,14 @@ Node 18 or newer. Nothing to clone, nothing to build.
139
139
 
140
140
  ## Your data
141
141
 
142
- `RESEARCH_LENS_DATA` points at a folder. One `.json` file per study, added or edited any time — the server re-reads on every question, so no restart.
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.
143
150
 
144
151
  ```json
145
152
  {
@@ -172,7 +179,7 @@ Only `id`, `round`, `segment` and the excerpt fields are required. Everything el
172
179
  **From a spreadsheet.** Fill in [`template.csv`](template.csv), one row per quote, then:
173
180
 
174
181
  ```bash
175
- npx research-lens-mcp-import my-study.csv "Study name"
182
+ npx -p research-lens-mcp research-lens-import my-study.csv "Study name"
176
183
  ```
177
184
 
178
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
@@ -59,19 +59,20 @@ function studyFiles() {
59
59
  * reads better than whatever the file happens to be called; the filename is the
60
60
  * fallback. Collisions get a numeric suffix so two studies never share a handle.
61
61
  */
62
- function keyFor(parsed, file, taken) {
62
+ function keyFor(parsed, file) {
63
63
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
64
- const base = slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
65
- if (!taken.some((t) => t.key === base))
66
- return base;
67
- let n = 2;
68
- while (taken.some((t) => t.key === `${base}-${n}`))
69
- n++;
70
- return `${base}-${n}`;
64
+ return slug(String(parsed.study?.name ?? "")) || slug(basename(file, ".json")) || "study";
71
65
  }
72
- /** Read every study fresh on each call, so edits take effect with no restart. */
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
+ */
73
74
  function loadAll() {
74
- const out = [];
75
+ const byKey = new Map();
75
76
  for (const file of studyFiles()) {
76
77
  let parsed;
77
78
  try {
@@ -83,9 +84,29 @@ function loadAll() {
83
84
  if (!Array.isArray(parsed?.sessions)) {
84
85
  throw new DataError(`"${basename(file)}" has no "sessions" array.`);
85
86
  }
86
- out.push({ ...parsed, key: keyFor(parsed, file, out), file });
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 };
87
108
  }
88
- return out;
109
+ return [...byKey.values()];
89
110
  }
90
111
  /**
91
112
  * Pick the studies a call applies to. With no name given, every study is used,
@@ -114,6 +135,9 @@ function allExcerpts(studies) {
114
135
  task: s.task,
115
136
  study: st.key,
116
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 ?? "" : ""),
117
141
  }))));
118
142
  }
119
143
  const sessionsOf = (studies) => studies.flatMap((s) => active(s.sessions));
@@ -178,8 +202,11 @@ function applySegment(rows, segment) {
178
202
  return { rows: loose, label: `, segment "${segment}"` };
179
203
  }
180
204
  /** Prefix a citation with the study only when more than one is in scope. */
181
- const cite = (h, multi) => `${multi ? h.study + " | " : ""}${h.participant} | R${h.round} | ${h.t}`;
182
- const server = new Server({ name: "research-lens", version: "2.2.0" }, { capabilities: { tools: {} } });
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: {} } });
183
210
  const STUDY_ARG = {
184
211
  study: {
185
212
  type: "string",
@@ -307,16 +334,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
307
334
  const rows = allExcerpts(studies);
308
335
  const sessions = sessionsOf(studies);
309
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;
310
339
  const scope = args.study ? ` in "${args.study}"` : multi ? ` across ${studies.length} studies` : "";
311
340
  if (name === "list_studies") {
312
341
  const lines = all.map((st) => {
313
342
  const people = new Set(st.sessions.map((s) => s.id));
314
- const rounds = [...new Set(st.sessions.map((s) => s.round))].sort();
343
+ const rounds = [...new Set(st.sessions.map((s) => s.round ?? 1))].sort();
315
344
  const segments = [...new Set(st.sessions.map((s) => s.segment))];
316
345
  const themes = [...new Set(st.sessions.flatMap((s) => s.excerpts.map((e) => e.theme)))];
317
346
  const quotes = st.sessions.reduce((a, s) => a + s.excerpts.length, 0);
318
347
  return (`${st.key}\n` +
319
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
+ : "") +
320
353
  ` participants: ${people.size} sessions: ${st.sessions.length} quotes: ${quotes}\n` +
321
354
  ` rounds: ${rounds.join(", ") || "none"}\n` +
322
355
  ` segments: ${segments.join(", ") || "none"}\n` +
@@ -336,14 +369,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
336
369
  return out(`No excerpts match "${args.query}"${scope}${seg.label}.`);
337
370
  return out(`${hits.length} excerpt(s) matching "${args.query}"${scope}${seg.label}:\n\n` +
338
371
  hits
339
- .map((h) => `[${cite(h, multi)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
372
+ .map((h) => `[${cite(h, multi, showRound)} | ${h.segment}] ${h.theme} / ${h.sentiment}\n ${quoteOrWithheld(h)}`)
340
373
  .join("\n\n"));
341
374
  }
342
375
  if (name === "quotes_by_theme") {
343
376
  const seg = applySegment(rows, args.segment);
344
377
  let hits = seg.rows.filter((r) => themeMatches(r.theme, args.theme));
345
378
  if (typeof args.round === "number")
346
- hits = hits.filter((r) => r.round === args.round);
379
+ hits = hits.filter((r) => (r.round ?? 1) === args.round);
347
380
  if (!hits.length) {
348
381
  // Distinguish "that theme does not exist" from "it exists, just not in
349
382
  // that segment". Conflating them reads as an absence of evidence.
@@ -363,7 +396,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
363
396
  const body = Object.entries(groups)
364
397
  .filter(([, v]) => v.length)
365
398
  .map(([k, v]) => `${k.toUpperCase()} (${v.length})\n` +
366
- v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n"))
399
+ v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n"))
367
400
  .join("\n\n");
368
401
  return out(`Theme "${args.theme}"${scope}${seg.label} — ${hits.length} excerpt(s)\n\n${body}`);
369
402
  }
@@ -413,13 +446,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
413
446
  return out(`Rounds can only be compared within one study. Name one of: ${all.map((s) => s.key).join(", ")}.`);
414
447
  }
415
448
  const theme = String(args.theme);
416
- const rounds = [...new Set(sessions.map((s) => s.round))].sort((a, b) => a - b);
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);
417
451
  if (rounds.length < 2) {
418
- return out(`Only round ${rounds[0] ?? "none"} exists${scope}, so there is nothing to compare.`);
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.`);
419
456
  }
420
457
  const summarise = (round) => {
421
- const hits = rows.filter((r) => r.theme === theme && r.round === round);
422
- 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);
423
460
  const scored = ss.filter((s) => typeof s.sus === "number" && s.sus > 0);
424
461
  return {
425
462
  pos: hits.filter((h) => h.sentiment === "positive").length,
@@ -474,7 +511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
474
511
  const mix = hits.filter((h) => h.sentiment === "mixed");
475
512
  const block = (label, v) => v.length
476
513
  ? `${label} (${v.length}):\n` +
477
- v.map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`).join("\n") +
514
+ v.map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`).join("\n") +
478
515
  "\n"
479
516
  : "";
480
517
  return out(`DRAFT FINDING — ${theme}${scope}\n\n` +
@@ -522,7 +559,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
522
559
  const segs = [...new Set(hits.map((h) => h.segment))];
523
560
  const quotes = hits
524
561
  .slice(0, 3)
525
- .map((h) => ` [${cite(h, multi)}] ${quoteOrWithheld(h)}`)
562
+ .map((h) => ` [${cite(h, multi, showRound)}] ${quoteOrWithheld(h)}`)
526
563
  .join("\n");
527
564
  return {
528
565
  n: who.size,
@@ -607,7 +644,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
607
644
  .map((x) => `${studyLabel(x.st)}\n` +
608
645
  ` ${x.people.size} participant(s), ${x.rws.length} excerpt(s)\n` +
609
646
  ` themes: ${[...new Set(x.rws.map((r) => r.theme))].join(", ")}\n` +
610
- ` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true)}] ${quoteOrWithheld(x.rws[0])}`)
647
+ ` e.g. [${cite({ ...x.rws[0], study: x.st.key }, true, showRound)}] ${quoteOrWithheld(x.rws[0])}`)
611
648
  .join("\n\n") +
612
649
  `\n\nRead these before commissioning new research on the topic.`);
613
650
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "research-lens-mcp",
3
- "version": "2.2.0",
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": {