@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/assets/dist/hippocampus.css +1 -0
- package/assets/hippocampus.scss +896 -0
- package/assets/javascript/blueprint.js +123 -0
- package/bin/hippocampus.js +106 -0
- package/docs/agent-roadmap.md +217 -0
- package/docs/agent-workflow.md +56 -0
- package/docs/agentic-patterns-to-port.md +273 -0
- package/docs/colour-pairings.md +41 -0
- package/docs/components.md +52 -0
- package/docs/copilot-ncrs-research-curator-agent.md +298 -0
- package/docs/custom-agents-usage.md +95 -0
- package/docs/design-actions-and-decisions.md +95 -0
- package/docs/gp-connect-real-data-to-production.md +66 -0
- package/docs/hippocampus-for-designers-and-researchers.md +261 -0
- package/docs/hippocampus-technical.md +373 -0
- package/docs/prompt-architecture-guardrails.md +39 -0
- package/docs/repeatable-research-round-ingestion.md +239 -0
- package/docs/research-knowledge-graph.md +552 -0
- package/docs/research-source-file-conventions.md +121 -0
- package/docs/source-model.md +252 -0
- package/index.js +47 -0
- package/lib/config.js +46 -0
- package/lib/context.js +59 -0
- package/lib/create-router.js +187 -0
- package/lib/discover-projects.js +45 -0
- package/lib/docs.js +72 -0
- package/lib/host-edit.js +151 -0
- package/lib/lint-knowledge.js +133 -0
- package/lib/load-knowledge.js +629 -0
- package/lib/package-paths.js +32 -0
- package/lib/paths.js +33 -0
- package/lib/resolve-root.js +84 -0
- package/lib/schema-version.js +96 -0
- package/lib/schema.js +346 -0
- package/package.json +70 -0
- package/scripts/audit-provenance.js +147 -0
- package/scripts/doctor.js +114 -0
- package/scripts/eject.js +51 -0
- package/scripts/import-blueprint-spreadsheet.js +242 -0
- package/scripts/import-design-actions.js +230 -0
- package/scripts/import-project-context.js +427 -0
- package/scripts/import-research-batch.js +160 -0
- package/scripts/import-research-deck.js +448 -0
- package/scripts/index-source-artefacts.js +261 -0
- package/scripts/init.js +232 -0
- package/scripts/install-skills.js +160 -0
- package/scripts/lib/artefact-text.js +347 -0
- package/scripts/lib/xlsx-grid.js +114 -0
- package/scripts/link-insights-to-needs.js +209 -0
- package/scripts/migrate-knowledge.js +73 -0
- package/scripts/promote-project-context-draft.js +125 -0
- package/scripts/promote-research-draft.js +236 -0
- package/scripts/smoke-routes.js +157 -0
- package/scripts/sync-project-context.js +236 -0
- package/scripts/sync-research-artefacts.js +294 -0
- package/scripts/triage-research-corpus.js +274 -0
- package/scripts/validate-knowledge.js +49 -0
- package/scripts/validate-skills.js +247 -0
- package/skills/COVERAGE-MATRIX.md +39 -0
- package/skills/README.md +77 -0
- package/skills/SKILL-FORMAT.md +100 -0
- package/skills/add-prototype-screen.skill.md +54 -0
- package/skills/add-scenario.skill.md +47 -0
- package/skills/add-user-needs.skill.md +51 -0
- package/skills/audit-knowledge-source.skill.md +47 -0
- package/skills/capture-route-review-pack.skill.md +52 -0
- package/skills/create-journey-from-scenario.skill.md +50 -0
- package/skills/deliver-service-slice.skill.md +65 -0
- package/skills/examples/README.md +15 -0
- package/skills/examples/non-copilot-research-ingestion.md +68 -0
- package/skills/examples/non-copilot-service-slice.md +64 -0
- package/skills/generate-service-slice.skill.md +57 -0
- package/skills/ingest-project-context.skill.md +58 -0
- package/skills/ingest-research-round.skill.md +60 -0
- package/skills/map-research-to-graph.skill.md +58 -0
- package/skills/record-accessibility-review.skill.md +52 -0
- package/skills/record-design-decision.skill.md +53 -0
- package/skills/review-research-import-draft.skill.md +45 -0
- package/skills/skills.json +464 -0
- package/skills/skills.schema.json +111 -0
- package/skills/structure-project-context-draft.skill.md +51 -0
- package/skills/structure-research-draft.skill.md +51 -0
- package/skills/triage-research-corpus.skill.md +51 -0
- package/skills/write-delivery-summary.skill.md +51 -0
- package/skills/write-route-review-summary.skill.md +55 -0
- package/views/hippocampus/_layout.html +45 -0
- package/views/hippocampus/blueprint-classic.html +39 -0
- package/views/hippocampus/blueprint.html +52 -0
- package/views/hippocampus/components/_blueprint-cell.njk +42 -0
- package/views/hippocampus/components/_blueprint-controls.njk +20 -0
- package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
- package/views/hippocampus/components/_empty-state.njk +12 -0
- package/views/hippocampus/components/_journey-step.njk +21 -0
- package/views/hippocampus/components/_list.njk +23 -0
- package/views/hippocampus/components/_summary-card.njk +15 -0
- package/views/hippocampus/doc.html +11 -0
- package/views/hippocampus/docs.html +19 -0
- package/views/hippocampus/evidence-impact.html +53 -0
- package/views/hippocampus/graph.html +48 -0
- package/views/hippocampus/handover-empty.html +12 -0
- package/views/hippocampus/handover.html +56 -0
- package/views/hippocampus/index.html +46 -0
- package/views/hippocampus/journey-flow.html +31 -0
- package/views/hippocampus/journey.html +23 -0
- package/views/hippocampus/not-found.html +10 -0
- package/views/hippocampus/project.html +102 -0
- package/views/hippocampus/research.html +68 -0
- package/views/hippocampus/route-review.html +83 -0
- package/views/hippocampus/scenarios.html +56 -0
- package/views/hippocampus/trace.html +113 -0
- package/views/hippocampus/user-needs.html +32 -0
- package/views/hippocampus/user.html +157 -0
- 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(/</g, '<')
|
|
327
|
+
.replace(/>/g, '>')
|
|
328
|
+
.replace(/"/g, '"')
|
|
329
|
+
.replace(/'/g, "'")
|
|
330
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
331
|
+
.replace(/&/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()
|