@hippo-digital/hippocampus 1.0.0-rc.1

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/assets/dist/hippocampus.css +1 -0
  5. package/assets/hippocampus.scss +896 -0
  6. package/assets/javascript/blueprint.js +123 -0
  7. package/bin/hippocampus.js +106 -0
  8. package/docs/agent-roadmap.md +217 -0
  9. package/docs/agent-workflow.md +56 -0
  10. package/docs/agentic-patterns-to-port.md +273 -0
  11. package/docs/colour-pairings.md +41 -0
  12. package/docs/components.md +52 -0
  13. package/docs/copilot-ncrs-research-curator-agent.md +298 -0
  14. package/docs/custom-agents-usage.md +95 -0
  15. package/docs/design-actions-and-decisions.md +95 -0
  16. package/docs/gp-connect-real-data-to-production.md +66 -0
  17. package/docs/hippocampus-for-designers-and-researchers.md +261 -0
  18. package/docs/hippocampus-technical.md +373 -0
  19. package/docs/prompt-architecture-guardrails.md +39 -0
  20. package/docs/repeatable-research-round-ingestion.md +239 -0
  21. package/docs/research-knowledge-graph.md +552 -0
  22. package/docs/research-source-file-conventions.md +121 -0
  23. package/docs/source-model.md +252 -0
  24. package/index.js +47 -0
  25. package/lib/config.js +46 -0
  26. package/lib/context.js +59 -0
  27. package/lib/create-router.js +187 -0
  28. package/lib/discover-projects.js +45 -0
  29. package/lib/docs.js +72 -0
  30. package/lib/host-edit.js +151 -0
  31. package/lib/lint-knowledge.js +133 -0
  32. package/lib/load-knowledge.js +629 -0
  33. package/lib/package-paths.js +32 -0
  34. package/lib/paths.js +33 -0
  35. package/lib/resolve-root.js +84 -0
  36. package/lib/schema-version.js +96 -0
  37. package/lib/schema.js +346 -0
  38. package/package.json +70 -0
  39. package/scripts/audit-provenance.js +147 -0
  40. package/scripts/doctor.js +114 -0
  41. package/scripts/eject.js +51 -0
  42. package/scripts/import-blueprint-spreadsheet.js +242 -0
  43. package/scripts/import-design-actions.js +230 -0
  44. package/scripts/import-project-context.js +427 -0
  45. package/scripts/import-research-batch.js +160 -0
  46. package/scripts/import-research-deck.js +448 -0
  47. package/scripts/index-source-artefacts.js +261 -0
  48. package/scripts/init.js +232 -0
  49. package/scripts/install-skills.js +160 -0
  50. package/scripts/lib/artefact-text.js +347 -0
  51. package/scripts/lib/xlsx-grid.js +114 -0
  52. package/scripts/link-insights-to-needs.js +209 -0
  53. package/scripts/migrate-knowledge.js +73 -0
  54. package/scripts/promote-project-context-draft.js +125 -0
  55. package/scripts/promote-research-draft.js +236 -0
  56. package/scripts/smoke-routes.js +157 -0
  57. package/scripts/sync-project-context.js +236 -0
  58. package/scripts/sync-research-artefacts.js +294 -0
  59. package/scripts/triage-research-corpus.js +274 -0
  60. package/scripts/validate-knowledge.js +49 -0
  61. package/scripts/validate-skills.js +247 -0
  62. package/skills/COVERAGE-MATRIX.md +39 -0
  63. package/skills/README.md +77 -0
  64. package/skills/SKILL-FORMAT.md +100 -0
  65. package/skills/add-prototype-screen.skill.md +54 -0
  66. package/skills/add-scenario.skill.md +47 -0
  67. package/skills/add-user-needs.skill.md +51 -0
  68. package/skills/audit-knowledge-source.skill.md +47 -0
  69. package/skills/capture-route-review-pack.skill.md +52 -0
  70. package/skills/create-journey-from-scenario.skill.md +50 -0
  71. package/skills/deliver-service-slice.skill.md +65 -0
  72. package/skills/examples/README.md +15 -0
  73. package/skills/examples/non-copilot-research-ingestion.md +68 -0
  74. package/skills/examples/non-copilot-service-slice.md +64 -0
  75. package/skills/generate-service-slice.skill.md +57 -0
  76. package/skills/ingest-project-context.skill.md +58 -0
  77. package/skills/ingest-research-round.skill.md +60 -0
  78. package/skills/map-research-to-graph.skill.md +58 -0
  79. package/skills/record-accessibility-review.skill.md +52 -0
  80. package/skills/record-design-decision.skill.md +53 -0
  81. package/skills/review-research-import-draft.skill.md +45 -0
  82. package/skills/skills.json +464 -0
  83. package/skills/skills.schema.json +111 -0
  84. package/skills/structure-project-context-draft.skill.md +51 -0
  85. package/skills/structure-research-draft.skill.md +51 -0
  86. package/skills/triage-research-corpus.skill.md +51 -0
  87. package/skills/write-delivery-summary.skill.md +51 -0
  88. package/skills/write-route-review-summary.skill.md +55 -0
  89. package/views/hippocampus/_layout.html +45 -0
  90. package/views/hippocampus/blueprint-classic.html +39 -0
  91. package/views/hippocampus/blueprint.html +52 -0
  92. package/views/hippocampus/components/_blueprint-cell.njk +42 -0
  93. package/views/hippocampus/components/_blueprint-controls.njk +20 -0
  94. package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
  95. package/views/hippocampus/components/_empty-state.njk +12 -0
  96. package/views/hippocampus/components/_journey-step.njk +21 -0
  97. package/views/hippocampus/components/_list.njk +23 -0
  98. package/views/hippocampus/components/_summary-card.njk +15 -0
  99. package/views/hippocampus/doc.html +11 -0
  100. package/views/hippocampus/docs.html +19 -0
  101. package/views/hippocampus/evidence-impact.html +53 -0
  102. package/views/hippocampus/graph.html +48 -0
  103. package/views/hippocampus/handover-empty.html +12 -0
  104. package/views/hippocampus/handover.html +56 -0
  105. package/views/hippocampus/index.html +46 -0
  106. package/views/hippocampus/journey-flow.html +31 -0
  107. package/views/hippocampus/journey.html +23 -0
  108. package/views/hippocampus/not-found.html +10 -0
  109. package/views/hippocampus/project.html +102 -0
  110. package/views/hippocampus/research.html +68 -0
  111. package/views/hippocampus/route-review.html +83 -0
  112. package/views/hippocampus/scenarios.html +56 -0
  113. package/views/hippocampus/trace.html +113 -0
  114. package/views/hippocampus/user-needs.html +32 -0
  115. package/views/hippocampus/user.html +157 -0
  116. package/views/hippocampus/users.html +32 -0
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Shared artefact text extraction.
3
+ *
4
+ * The original importer understood PowerPoint and PDF only, and dropped
5
+ * PowerPoint speaker notes. Real research folders are mostly Word and Excel,
6
+ * and playback decks carry a tenth of their content in the notes pane.
7
+ *
8
+ * Every extractor returns the same shape so downstream code does not care
9
+ * which format the artefact was:
10
+ *
11
+ * { sourceType, sections: [{ number, title, text[], notes[] }] }
12
+ *
13
+ * A "section" is a slide, a PDF page, a Word heading block, or a worksheet.
14
+ */
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const AdmZip = require('adm-zip')
19
+ const { XMLParser } = require('fast-xml-parser')
20
+
21
+ const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })
22
+ const attrParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', textNodeName: '#text' })
23
+
24
+ const extensions = {
25
+ '.pptx': 'powerpoint deck',
26
+ '.pdf': 'pdf document',
27
+ '.docx': 'word document',
28
+ '.dotx': 'word template',
29
+ '.xlsx': 'excel workbook',
30
+ '.csv': 'csv extract',
31
+ '.txt': 'text note',
32
+ '.md': 'markdown note',
33
+ '.url': 'external link'
34
+ }
35
+
36
+ function supports (filePath) {
37
+ return Object.prototype.hasOwnProperty.call(extensions, path.extname(filePath).toLowerCase())
38
+ }
39
+
40
+ function sourceTypeFor (filePath) {
41
+ return extensions[path.extname(filePath).toLowerCase()] || 'unknown artefact'
42
+ }
43
+
44
+ async function extractArtefact (filePath) {
45
+ const extension = path.extname(filePath).toLowerCase()
46
+ const sourceType = sourceTypeFor(filePath)
47
+
48
+ if (extension === '.pptx') return { sourceType, sections: extractPowerPoint(filePath) }
49
+ if (extension === '.pdf') return { sourceType, sections: await extractPdf(filePath) }
50
+ if (extension === '.docx' || extension === '.dotx') return { sourceType, sections: extractWord(filePath) }
51
+ if (extension === '.xlsx') return { sourceType, sections: extractWorkbook(filePath) }
52
+ if (extension === '.csv') return { sourceType, sections: extractDelimited(filePath) }
53
+ if (extension === '.txt' || extension === '.md') return { sourceType, sections: extractPlainText(filePath) }
54
+ if (extension === '.url') return { sourceType, sections: extractShortcut(filePath) }
55
+
56
+ throw new Error(`Unsupported research artefact extension ${extension || '(none)'}`)
57
+ }
58
+
59
+ /* ---------------------------------------------------------------- PowerPoint */
60
+
61
+ function extractPowerPoint (deckPath) {
62
+ const zip = new AdmZip(deckPath)
63
+ const notesByNumber = readSpeakerNotes(zip)
64
+
65
+ return zip.getEntries()
66
+ .filter((entry) => /^ppt\/slides\/slide\d+\.xml$/.test(entry.entryName))
67
+ .sort((a, b) => numberIn(a.entryName) - numberIn(b.entryName))
68
+ .map((entry) => {
69
+ const number = numberIn(entry.entryName)
70
+ const text = collectText(parser.parse(entry.getData().toString('utf8'))).map(normalise).filter(Boolean)
71
+ return {
72
+ number,
73
+ title: text[0] || `Slide ${number}`,
74
+ text,
75
+ notes: notesByNumber.get(number) || []
76
+ }
77
+ })
78
+ }
79
+
80
+ function readSpeakerNotes (zip) {
81
+ const notes = new Map()
82
+ for (const entry of zip.getEntries()) {
83
+ if (!/^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(entry.entryName)) continue
84
+ const lines = collectText(parser.parse(entry.getData().toString('utf8')))
85
+ .map(normalise)
86
+ // the slide-number placeholder is repeated into every notes page
87
+ .filter((line) => line && !/^\d{1,3}$/.test(line))
88
+ if (lines.length) notes.set(numberIn(entry.entryName), lines)
89
+ }
90
+ return notes
91
+ }
92
+
93
+ /* ----------------------------------------------------------------------- PDF */
94
+
95
+ /**
96
+ * pdf-parse 2.x reaches for DOMMatrix at module load, which normally arrives with
97
+ * the optional @napi-rs/canvas native binding. That binding is platform-specific,
98
+ * so a repo installed on macOS cannot load it on Linux CI and every PDF fails with
99
+ * "DOMMatrix is not defined". Text extraction never rasterises anything, so a
100
+ * minimal stand-in is enough and keeps PDF support working everywhere.
101
+ */
102
+ function ensurePdfGlobals () {
103
+ if (typeof globalThis.DOMMatrix !== 'undefined') return
104
+ globalThis.DOMMatrix = class DOMMatrix {
105
+ constructor (init) {
106
+ this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.e = 0; this.f = 0
107
+ if (Array.isArray(init) && init.length >= 6) [this.a, this.b, this.c, this.d, this.e, this.f] = init
108
+ }
109
+
110
+ translate () { return this }
111
+ scale () { return this }
112
+ multiply () { return this }
113
+ inverse () { return this }
114
+ transformPoint (point) { return point }
115
+ }
116
+ }
117
+
118
+ async function extractPdf (pdfPath) {
119
+ ensurePdfGlobals()
120
+ const { PDFParse } = require('pdf-parse')
121
+ const pdf = new PDFParse({ data: fs.readFileSync(pdfPath) })
122
+ try {
123
+ const result = await pdf.getText({ pageJoiner: '' })
124
+ return result.pages.map((page) => {
125
+ const text = page.text.split(/\n+/).map(normalise).filter(Boolean)
126
+ return { number: page.num, title: text[0] || `Page ${page.num}`, text, notes: [] }
127
+ })
128
+ } finally {
129
+ await pdf.destroy()
130
+ }
131
+ }
132
+
133
+ /* ---------------------------------------------------------------------- Word */
134
+
135
+ /**
136
+ * Word has no page concept we can recover from the XML, so sections are cut at
137
+ * heading paragraphs. That keeps a finding attached to the heading it sat under,
138
+ * which is what the structuring step needs in order to scope an insight.
139
+ */
140
+ function extractWord (docPath) {
141
+ const zip = new AdmZip(docPath)
142
+ const entry = zip.getEntry('word/document.xml')
143
+ if (!entry) return []
144
+
145
+ const xml = entry.getData().toString('utf8')
146
+ const paragraphs = []
147
+
148
+ for (const match of xml.match(/<w:p[ >][\s\S]*?<\/w:p>|<w:p\/>/g) || []) {
149
+ const runs = [...match.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g)].map((m) => m[1])
150
+ const text = normalise(decodeXml(runs.join('')))
151
+ if (!text) continue
152
+ const style = (match.match(/<w:pStyle w:val="([^"]+)"/) || [])[1] || 'Normal'
153
+ paragraphs.push({ style, text, heading: /^Heading[1-6]$|^Title$/i.test(style) })
154
+ }
155
+
156
+ const sections = []
157
+ let current = null
158
+ for (const paragraph of paragraphs) {
159
+ if (paragraph.heading || !current) {
160
+ current = { number: sections.length + 1, title: paragraph.text, text: [], notes: [] }
161
+ sections.push(current)
162
+ if (paragraph.heading) continue
163
+ }
164
+ current.text.push(paragraph.text)
165
+ }
166
+
167
+ // A document with no heading styles still deserves a usable title.
168
+ if (sections.length === 1 && sections[0].title === sections[0].text[0]) {
169
+ sections[0].title = path.basename(docPath, path.extname(docPath))
170
+ }
171
+
172
+ return sections
173
+ }
174
+
175
+ /* --------------------------------------------------------------------- Excel */
176
+
177
+ /**
178
+ * Research teams keep insight and design-action trackers in Excel. A flat cell
179
+ * dump loses the column a value came from, so each row is emitted as
180
+ * "Header: value" pairs and the header row is kept as its own line.
181
+ */
182
+ function extractWorkbook (workbookPath) {
183
+ const zip = new AdmZip(workbookPath)
184
+ const shared = readSharedStrings(zip)
185
+ const sheetNames = readSheetNames(zip)
186
+
187
+ return zip.getEntries()
188
+ .filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.entryName))
189
+ .sort((a, b) => numberIn(a.entryName) - numberIn(b.entryName))
190
+ .map((entry, index) => {
191
+ const grid = readGrid(entry.getData().toString('utf8'), shared)
192
+ const title = sheetNames[index] || `Sheet ${index + 1}`
193
+ const headers = grid.length ? grid[0] : []
194
+ const text = []
195
+
196
+ if (headers.filter(Boolean).length) text.push(`Columns: ${headers.filter(Boolean).join(' | ')}`)
197
+
198
+ for (const row of grid.slice(1)) {
199
+ const pairs = row
200
+ .map((value, column) => {
201
+ if (!value) return null
202
+ const header = headers[column] || `Column ${column + 1}`
203
+ return `${header}: ${readableCell(header, value)}`
204
+ })
205
+ .filter(Boolean)
206
+ if (pairs.length) text.push(pairs.join(' | '))
207
+ }
208
+
209
+ return { number: index + 1, title, text, notes: [] }
210
+ })
211
+ .filter((sheet) => sheet.text.length)
212
+ }
213
+
214
+ function readSheetNames (zip) {
215
+ const entry = zip.getEntry('xl/workbook.xml')
216
+ if (!entry) return []
217
+ const parsed = attrParser.parse(entry.getData().toString('utf8'))
218
+ const sheets = parsed?.workbook?.sheets?.sheet
219
+ return asArray(sheets).map((sheet) => String(sheet['@_name'] || '').trim())
220
+ }
221
+
222
+ function readSharedStrings (zip) {
223
+ const entry = zip.getEntry('xl/sharedStrings.xml')
224
+ if (!entry) return []
225
+ const xml = entry.getData().toString('utf8')
226
+ return (xml.match(/<si>[\s\S]*?<\/si>/g) || []).map((si) => {
227
+ const runs = [...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1])
228
+ return normalise(decodeXml(runs.join('')))
229
+ })
230
+ }
231
+
232
+ function readGrid (xml, shared) {
233
+ const grid = []
234
+ for (const rowXml of xml.match(/<row[ >][\s\S]*?<\/row>|<row[^>]*\/>/g) || []) {
235
+ const cells = []
236
+ for (const cellMatch of rowXml.match(/<c[ >][\s\S]*?<\/c>|<c[^>]*\/>/g) || []) {
237
+ const reference = (cellMatch.match(/\br="([A-Z]+)\d+"/) || [])[1]
238
+ const type = (cellMatch.match(/\bt="([^"]+)"/) || [])[1] || 'n'
239
+ const column = reference ? columnIndex(reference) : cells.length
240
+ cells[column] = cellValue(cellMatch, type, shared)
241
+ }
242
+ grid.push([...cells].map((value) => value || ''))
243
+ }
244
+ return grid
245
+ }
246
+
247
+ function cellValue (cellXml, type, shared) {
248
+ if (type === 'inlineStr') {
249
+ const runs = [...cellXml.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1])
250
+ return normalise(decodeXml(runs.join('')))
251
+ }
252
+ const raw = (cellXml.match(/<v>([\s\S]*?)<\/v>/) || [])[1]
253
+ if (raw === undefined) return ''
254
+ if (type === 'e') return ''
255
+ if (type === 's') return shared[Number(decodeXml(raw))] || ''
256
+ return normalise(decodeXml(raw))
257
+ }
258
+
259
+ /**
260
+ * Excel stores dates as serial numbers. A tracker column called "Date Received"
261
+ * holding 46174 is a date, not a quantity, and a research round needs the real
262
+ * date, so serials in date-ish columns are rendered back as YYYY-MM-DD.
263
+ */
264
+ function readableCell (header, value) {
265
+ if (!/date|received|agreed|when|day/i.test(header)) return value
266
+ const serial = Number(value)
267
+ if (!Number.isFinite(serial) || serial < 20000 || serial > 60000) return value
268
+ const epoch = Date.UTC(1899, 11, 30)
269
+ return new Date(epoch + Math.round(serial) * 86400000).toISOString().slice(0, 10)
270
+ }
271
+
272
+ function columnIndex (letters) {
273
+ let index = 0
274
+ for (const character of letters) index = index * 26 + (character.charCodeAt(0) - 64)
275
+ return index - 1
276
+ }
277
+
278
+ /* ------------------------------------------------------------ plain formats */
279
+
280
+ function extractDelimited (filePath) {
281
+ const rows = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).map(normalise).filter(Boolean)
282
+ if (!rows.length) return []
283
+ return [{ number: 1, title: path.basename(filePath), text: rows, notes: [] }]
284
+ }
285
+
286
+ function extractPlainText (filePath) {
287
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).map(normalise).filter(Boolean)
288
+ if (!lines.length) return []
289
+ return [{ number: 1, title: lines[0], text: lines, notes: [] }]
290
+ }
291
+
292
+ /**
293
+ * Windows .url shortcuts are how this team records MURAL boards, Confluence
294
+ * pages and survey links. They hold no findings, but they are the pointer to
295
+ * where the finding lives, so they belong in the graph as evidence.
296
+ */
297
+ function extractShortcut (filePath) {
298
+ const target = (fs.readFileSync(filePath, 'utf8').match(/^URL=(.+)$/m) || [])[1]
299
+ if (!target) return []
300
+ return [{
301
+ number: 1,
302
+ title: path.basename(filePath, path.extname(filePath)),
303
+ text: [`External reference: ${target.trim()}`],
304
+ notes: []
305
+ }]
306
+ }
307
+
308
+ /* ------------------------------------------------------------------- helpers */
309
+
310
+ function collectText (value, includePrimitive = false) {
311
+ if (value === null || value === undefined) return []
312
+ if (typeof value === 'string' || typeof value === 'number') return includePrimitive ? [String(value)] : []
313
+ if (Array.isArray(value)) return value.flatMap((child) => collectText(child, includePrimitive))
314
+ if (typeof value !== 'object') return []
315
+
316
+ const text = []
317
+ for (const [key, child] of Object.entries(value)) {
318
+ if (key === 't') text.push(...collectText(child, true))
319
+ else text.push(...collectText(child, false))
320
+ }
321
+ return text
322
+ }
323
+
324
+ function decodeXml (value) {
325
+ return String(value)
326
+ .replace(/&lt;/g, '<')
327
+ .replace(/&gt;/g, '>')
328
+ .replace(/&quot;/g, '"')
329
+ .replace(/&apos;/g, "'")
330
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
331
+ .replace(/&amp;/g, '&')
332
+ }
333
+
334
+ function normalise (value) {
335
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
336
+ }
337
+
338
+ function numberIn (entryName) {
339
+ return Number((entryName.match(/(\d+)\.xml$/) || [])[1] || 0)
340
+ }
341
+
342
+ function asArray (value) {
343
+ if (value === null || value === undefined) return []
344
+ return Array.isArray(value) ? value : [value]
345
+ }
346
+
347
+ module.exports = { extractArtefact, supports, sourceTypeFor, extensions }
@@ -0,0 +1,114 @@
1
+ const AdmZip = require('adm-zip')
2
+ const { XMLParser } = require('fast-xml-parser')
3
+
4
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', textNodeName: '#text' })
5
+
6
+ function openWorkbook (filePath) {
7
+ const zip = new AdmZip(filePath)
8
+ const sharedStrings = readSharedStrings(zip)
9
+ const sheetFiles = readSheetFileMap(zip)
10
+ return { zip, sharedStrings, sheetFiles }
11
+ }
12
+
13
+ function readSheetFileMap (zip) {
14
+ const workbookDoc = parser.parse(zip.readAsText('xl/workbook.xml'))
15
+ const relsDoc = parser.parse(zip.readAsText('xl/_rels/workbook.xml.rels'))
16
+
17
+ const relTargets = new Map(
18
+ asArray(relsDoc.Relationships.Relationship).map((rel) => [rel['@_Id'], rel['@_Target']])
19
+ )
20
+
21
+ const sheets = asArray(workbookDoc.workbook.sheets.sheet)
22
+ return new Map(sheets.map((sheet) => [
23
+ sheet['@_name'].trim(),
24
+ resolveTarget(relTargets.get(sheet['@_r:id']))
25
+ ]))
26
+ }
27
+
28
+ /**
29
+ * Excel writes sheet relationship targets relative to xl/ ("worksheets/sheet1.xml"),
30
+ * openpyxl writes them absolute from the package root ("/xl/worksheets/sheet1.xml").
31
+ * Both are valid, so handle both rather than only the files Excel produced.
32
+ */
33
+ function resolveTarget (target) {
34
+ if (!target) return null
35
+ const clean = String(target).replace(/^\.\//, '')
36
+ return clean.startsWith('/') ? clean.slice(1) : `xl/${clean}`
37
+ }
38
+
39
+ function readSharedStrings (zip) {
40
+ if (!zip.getEntry('xl/sharedStrings.xml')) return []
41
+ const doc = parser.parse(zip.readAsText('xl/sharedStrings.xml'))
42
+ const siList = asArray(doc.sst?.si)
43
+ return siList.map(extractRichText)
44
+ }
45
+
46
+ function extractRichText (si) {
47
+ if (si == null) return ''
48
+ if (typeof si === 'string') return si
49
+ if (si.t != null) return textValue(si.t)
50
+ if (si.r) return asArray(si.r).map((run) => textValue(run.t)).join('')
51
+ return ''
52
+ }
53
+
54
+ function textValue (node) {
55
+ if (node == null) return ''
56
+ return typeof node === 'string' ? node : (node['#text'] ?? String(node))
57
+ }
58
+
59
+ function colLetterToIndex (letters) {
60
+ let index = 0
61
+ for (const char of letters) index = index * 26 + (char.charCodeAt(0) - 64)
62
+ return index - 1
63
+ }
64
+
65
+ function parseCellRef (ref) {
66
+ const match = /^([A-Z]+)(\d+)$/.exec(ref)
67
+ return { col: colLetterToIndex(match[1]), row: parseInt(match[2], 10) }
68
+ }
69
+
70
+ /**
71
+ * Reads a sheet (by its display name in the workbook) into a 2D grid of
72
+ * plain-text cell values, keyed [rowNumber][columnIndex] (both 1-based row,
73
+ * 0-based column, matching how spreadsheet UIs label columns A, B, C...).
74
+ */
75
+ function readSheetGrid (workbook, sheetName) {
76
+ const sheetFile = workbook.sheetFiles.get(sheetName)
77
+ if (!sheetFile) {
78
+ throw new Error(`Sheet "${sheetName}" not found. Available sheets: ${[...workbook.sheetFiles.keys()].join(', ')}`)
79
+ }
80
+
81
+ const doc = parser.parse(workbook.zip.readAsText(sheetFile))
82
+ const rows = asArray(doc.worksheet.sheetData.row)
83
+ const grid = []
84
+
85
+ for (const row of rows) {
86
+ const rowIndex = parseInt(row['@_r'], 10)
87
+ for (const cell of asArray(row.c)) {
88
+ const { col } = parseCellRef(cell['@_r'])
89
+ const value = readCellValue(cell, workbook.sharedStrings)
90
+ if (value === '') continue
91
+ grid[rowIndex] = grid[rowIndex] || []
92
+ grid[rowIndex][col] = value
93
+ }
94
+ }
95
+
96
+ return grid
97
+ }
98
+
99
+ function readCellValue (cell, sharedStrings) {
100
+ if (cell.v != null) {
101
+ const raw = (typeof cell.v === 'object') ? cell.v['#text'] : cell.v
102
+ if (cell['@_t'] === 's') return sharedStrings[parseInt(raw, 10)] || ''
103
+ return String(raw)
104
+ }
105
+ if (cell.is) return extractRichText(cell.is)
106
+ return ''
107
+ }
108
+
109
+ function asArray (value) {
110
+ if (value == null) return []
111
+ return Array.isArray(value) ? value : [value]
112
+ }
113
+
114
+ module.exports = { openWorkbook, readSheetGrid }
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Link insights to the user needs they bear on.
4
+ *
5
+ * The first attempt matched the tracker's short Need phrase ("Need location-only
6
+ * search") against need statements using a hand-written synonym list. It found 80
7
+ * links and missed seven phrases entirely - not because those needs were absent,
8
+ * but because a four-word phrase is too thin to match on and the vocabularies
9
+ * differ. All seven were already stated by the personas.
10
+ *
11
+ * This does two things differently:
12
+ *
13
+ * 1. It matches on the whole insight - title and summary - not the Need phrase
14
+ * alone, so there is enough text to score against.
15
+ * 2. It only considers needs belonging to the users the tracker says the insight
16
+ * affects. That is recorded data, not a guess, and it stops a safeguarding
17
+ * finding attaching itself to a midwife's need for sharing a keyword.
18
+ *
19
+ * Rare words carry more weight than common ones, so "safeguarding" counts for
20
+ * more than "patient", which almost every need contains.
21
+ *
22
+ * node scripts/link-insights-to-needs.js [--round round-16] [--dry-run]
23
+ */
24
+
25
+ const fs = require('fs')
26
+ const path = require('path')
27
+ const { createContextOrExit } = require('../lib/context')
28
+ const ctx = createContextOrExit()
29
+ const { knowledgeSourceDir } = ctx.paths
30
+
31
+ // The tracker names user groups; the knowledge base names users.
32
+ const GROUP_TO_USERS = [
33
+ [/safeguarding/i, ['admin-safeguarding', 'clinical-safeguarding']],
34
+ [/urgent\s*(&|and)\s*emergency|u&e/i, ['clinical-advisor', 'clinical-paramedic', 'clinical-ed-consultant']],
35
+ [/child health/i, ['admin-child-health']],
36
+ [/data quality/i, ['admin-data-quality']],
37
+ [/policy admin|overseas|funding|chargeable/i, ['admin-overseas-visitor-manager']],
38
+ [/pharmac/i, ['clinical-ed-consultant']],
39
+ [/screen(ing)? admin/i, ['admin-screening']],
40
+ [/booking|reception/i, ['admin-booking-and-reception']],
41
+ [/midwife|maternity/i, ['clinical-midwife']],
42
+ [/nurs/i, ['clinical-nursing']],
43
+ [/gp|out.of.hours/i, ['clinical-out-of-hours-gp']]
44
+ ]
45
+
46
+ const STOP = new Set(`a an and are as at be but by can for from has have i if in is it its me my need needs not of on or our so that the their them then there these they this to use used user users want we what when where which who will with would you your also more most only other same some such than very
47
+ nhs ncrs patient patients record records search searching system systems data information staff people person`.split(/\s+/))
48
+
49
+ function tokens (text) {
50
+ return (String(text).toLowerCase().match(/[a-z][a-z-]{2,}/g) || [])
51
+ .map((t) => t.replace(/-$/, ''))
52
+ .filter((t) => t.length > 2 && !STOP.has(t))
53
+ }
54
+
55
+ function main () {
56
+ const args = parseArgs(process.argv.slice(2))
57
+ const read = (f) => JSON.parse(fs.readFileSync(path.join(knowledgeSourceDir, f), 'utf8'))
58
+
59
+ const needs = read('needs.json')
60
+ const participants = read('participants.json')
61
+ const insights = read('insights.json').filter((i) => !args.round || i.roundId === args.round)
62
+ const relationships = read('relationships.json')
63
+ const insightIds = new Set(read('insights.json').map((i) => i.id))
64
+
65
+ // Rare words are informative; words in every need are not.
66
+ const df = new Map()
67
+ const needTokens = new Map()
68
+ for (const need of needs) {
69
+ const set = new Set(tokens(need.statement))
70
+ needTokens.set(need.id, set)
71
+ for (const t of set) df.set(t, (df.get(t) || 0) + 1)
72
+ }
73
+ const weight = (t) => Math.log(needs.length / ((df.get(t) || 0) + 1)) + 1
74
+
75
+ const links = []
76
+ const report = []
77
+
78
+ for (const insight of insights) {
79
+ // Round 16's tracker names the affected user groups on every row. Other
80
+ // rounds have no such column, so the candidate users come from who actually
81
+ // took part in that round - an insight cannot bear on the needs of a user
82
+ // group that was not in the room.
83
+ const affects = (insight.summary.match(/Affects ([^.]+)\./) || [])[1] || ''
84
+ const users = new Set(
85
+ GROUP_TO_USERS.filter(([re]) => re.test(affects)).flatMap(([, ids]) => ids)
86
+ )
87
+ if (!users.size) {
88
+ const roles = participants
89
+ .filter((p) => p.roundIds.includes(insight.roundId))
90
+ .map((p) => p.role)
91
+ .join(' | ')
92
+ for (const [re, ids] of GROUP_TO_USERS) if (re.test(roles)) for (const id of ids) users.add(id)
93
+ }
94
+ if (!users.size) { report.push({ insight, users: 0, matched: [], reason: 'no user groups recorded and no participants in this round' }); continue }
95
+
96
+ const query = new Set(tokens(`${insight.title} ${insight.summary}`))
97
+ const candidates = needs.filter((n) => users.has(n.userId))
98
+
99
+ const scored = candidates.map((need) => {
100
+ const have = needTokens.get(need.id)
101
+ let score = 0
102
+ let distinctive = 0
103
+ for (const t of query) {
104
+ if (!have.has(t)) continue
105
+ const w = weight(t)
106
+ score += w
107
+ if (w >= 2) distinctive++
108
+ }
109
+ // Normalise by the need's own length so a long statement does not win by
110
+ // sheer surface area.
111
+ return { need, score: score / Math.sqrt(have.size || 1), distinctive }
112
+ }).sort((a, b) => b.score - a.score)
113
+
114
+ const best = scored[0]?.score || 0
115
+ // Keep needs close to the strongest match for this insight rather than
116
+ // applying one absolute cut-off across insights of very different length.
117
+ // A high cumulative score built from many common words is not a match. Two
118
+ // genuinely uncommon words in common is. Without this an insight about
119
+ // responding to a 999 call at an address attached itself to a need about
120
+ // allergy information, on general clinical vocabulary alone.
121
+ const kept = scored.filter((s) => s.score >= best * 0.70 && s.distinctive >= 2).slice(0, 5)
122
+
123
+ for (const { need } of kept) {
124
+ links.push({ id: `rel-${insight.id}-tests-${need.id}`, from: insight.id, type: 'tests-need', to: need.id })
125
+ }
126
+ report.push({ insight, users: users.size, matched: kept })
127
+ }
128
+
129
+ // Replace only the links this script owns: insight -> need. Scenario and screen
130
+ // links to needs are somebody else's and must survive.
131
+ const kept = relationships.filter((r) => !(r.type === 'tests-need' && insightIds.has(r.from)))
132
+ const merged = [...new Map([...kept, ...links].map((r) => [r.id, r])).values()]
133
+
134
+ const before = relationships.filter((r) => r.type === 'tests-need' && insightIds.has(r.from)).length
135
+ console.log(`insight tests-need links: ${before} -> ${links.length}`)
136
+ console.log(`insights linked: ${report.filter((r) => r.matched.length).length} of ${insights.length}`)
137
+ console.log(`distinct needs reached: ${new Set(links.map((l) => l.to)).size} of ${needs.length}`)
138
+
139
+ const none = report.filter((r) => !r.matched.length)
140
+ if (none.length) {
141
+ console.log(`\nunlinked insights (${none.length}):`)
142
+ for (const r of none) console.log(` ${r.reason || 'no need scored'}: ${r.insight.title.slice(0, 90)}`)
143
+ }
144
+
145
+ if (args.dryRun) {
146
+ console.log('\nDry run. Source files were not changed.')
147
+ console.log('\nweakest kept link per insight (precision check):')
148
+ for (const r of report.filter((x) => x.matched.length)) {
149
+ const last = r.matched[r.matched.length - 1]
150
+ console.log(` ${last.score.toFixed(2)} [${last.need.userId.padEnd(30)}] ${last.need.statement.slice(0, 80)}`)
151
+ console.log(` for: ${r.insight.title.slice(0, 78)}`)
152
+ }
153
+ console.log('\nsample:')
154
+ for (const r of report.filter((x) => x.matched.length).slice(0, 3)) {
155
+ console.log(`\n ${r.insight.title.slice(0, 100)}`)
156
+ for (const m of r.matched) console.log(` ${m.score.toFixed(2)} [${m.need.userId}] ${m.need.statement.slice(0, 110)}`)
157
+ }
158
+ return
159
+ }
160
+
161
+ fs.writeFileSync(path.join(knowledgeSourceDir, 'relationships.json'), `${JSON.stringify(merged, null, 2)}\n`)
162
+ console.log(`\nrelationships.json: ${relationships.length} -> ${merged.length}`)
163
+
164
+ // Similarity matching has a fuzzy boundary by nature. Rather than pretend a
165
+ // threshold settles it, every link is written out with its score so a person
166
+ // can prune the weak ones - the same review-first habit the ingestion
167
+ // workflow uses for drafts.
168
+ const reviewDir = path.join(ctx.paths.artefactsDir, 'research-triage')
169
+ fs.mkdirSync(reviewDir, { recursive: true })
170
+ const reviewPath = path.join(reviewDir, `${new Date().toISOString().slice(0, 10)}-insight-need-links${args.round ? `-${args.round}` : ''}.md`)
171
+
172
+ const lines = [
173
+ `# Insight to need links${args.round ? `: ${args.round}` : ''}`,
174
+ '',
175
+ `Generated by \`scripts/link-insights-to-needs.js\`. ${links.length} links across ${report.filter((r) => r.matched.length).length} insights.`,
176
+ '',
177
+ 'Scores are relative within an insight, not across insights. The lowest-scoring link',
178
+ 'under each insight is the one most worth checking. Delete a link from',
179
+ '`relationships.json` if it does not hold.',
180
+ ''
181
+ ]
182
+ for (const r of report) {
183
+ lines.push(`## ${r.insight.title}`, '')
184
+ if (!r.matched.length) {
185
+ lines.push(`_No link. ${r.reason || 'No need shared two distinctive terms with this insight.'}_`, '')
186
+ continue
187
+ }
188
+ lines.push('| Score | User | Need |', '| ---: | --- | --- |')
189
+ for (const m of r.matched) {
190
+ lines.push(`| ${m.score.toFixed(2)} | \`${m.need.userId}\` | ${m.need.statement.replace(/\|/g, '\\|').slice(0, 200)} |`)
191
+ }
192
+ lines.push('')
193
+ }
194
+ fs.writeFileSync(reviewPath, `${lines.join('\n')}\n`)
195
+ console.log(`review pack: ${path.relative(ctx.root, reviewPath)}`)
196
+ }
197
+
198
+ function parseArgs (argv) {
199
+ const parsed = {}
200
+ for (let i = 0; i < argv.length; i++) {
201
+ if (!argv[i].startsWith('--')) continue
202
+ const [k, v] = argv[i].slice(2).split('=', 2)
203
+ const key = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
204
+ parsed[key] = v !== undefined ? v : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true)
205
+ }
206
+ return parsed
207
+ }
208
+
209
+ main()