@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,160 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { spawn } = require('child_process')
4
+ const { createContextOrExit } = require('../lib/context')
5
+ const ctx = createContextOrExit()
6
+ const { researchImportsDir } = ctx.paths
7
+
8
+ const supportedExtensions = new Set(['.pptx', '.pdf'])
9
+
10
+ async function main () {
11
+ const args = parseArgs(process.argv.slice(2))
12
+ if (!args.source || !args.batchId) {
13
+ throw new Error('Usage: npm run import:research-batch -- --source path/to/files --batch-id batch-id [--date YYYY-MM-DD] [--concurrency 4]')
14
+ }
15
+
16
+ const sourceDir = path.resolve(args.source)
17
+ const batchDir = path.join(researchImportsDir, 'batches', args.batchId)
18
+ const draftsDir = path.join(batchDir, 'drafts')
19
+ const files = listFiles(sourceDir)
20
+ const supported = files.filter((file) => supportedExtensions.has(path.extname(file).toLowerCase()))
21
+ const unsupported = files.filter((file) => !supportedExtensions.has(path.extname(file).toLowerCase()))
22
+ const concurrency = Number(args.concurrency || 4)
23
+ const date = args.date || new Date().toISOString().slice(0, 10)
24
+
25
+ fs.mkdirSync(draftsDir, { recursive: true })
26
+
27
+ const results = await runPool(supported, concurrency, (file) => importDeck({
28
+ file,
29
+ batchId: args.batchId,
30
+ draftsDir,
31
+ date
32
+ }))
33
+
34
+ const manifest = {
35
+ batchId: args.batchId,
36
+ source: path.relative(ctx.root, sourceDir),
37
+ importedAt: new Date().toISOString(),
38
+ concurrency,
39
+ filesFound: files.length,
40
+ filesProcessed: results.filter((result) => result.ok).length,
41
+ draftsCreated: results.filter((result) => result.ok).length,
42
+ failed: results.filter((result) => !result.ok),
43
+ unsupported: unsupported.map((file) => ({
44
+ path: path.relative(ctx.root, file),
45
+ reason: `Unsupported extension ${path.extname(file) || '(none)'}`
46
+ })),
47
+ drafts: results.filter((result) => result.ok).map((result) => ({
48
+ draft: result.draft,
49
+ review: result.review
50
+ }))
51
+ }
52
+
53
+ const manifestPath = path.join(batchDir, 'manifest.json')
54
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
55
+
56
+ console.log(`Files found: ${manifest.filesFound}`)
57
+ console.log(`Drafts created: ${manifest.draftsCreated}`)
58
+ console.log(`Unsupported files: ${manifest.unsupported.length}`)
59
+ console.log(`Failed files: ${manifest.failed.length}`)
60
+ console.log(`Manifest written to ${manifestPath}`)
61
+
62
+ if (manifest.failed.length) process.exitCode = 1
63
+ }
64
+
65
+ function parseArgs (args) {
66
+ const parsed = {}
67
+ for (let index = 0; index < args.length; index++) {
68
+ const arg = args[index]
69
+ if (!arg.startsWith('--')) continue
70
+ const [rawKey, value] = arg.slice(2).split('=', 2)
71
+ const key = camelCase(rawKey)
72
+ if (value !== undefined) {
73
+ parsed[key] = value
74
+ } else if (args[index + 1] && !args[index + 1].startsWith('--')) {
75
+ parsed[key] = args[index + 1]
76
+ index++
77
+ } else {
78
+ parsed[key] = true
79
+ }
80
+ }
81
+ return parsed
82
+ }
83
+
84
+ function listFiles (dir) {
85
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
86
+ const fullPath = path.join(dir, entry.name)
87
+ if (entry.isDirectory()) return listFiles(fullPath)
88
+ if (entry.isFile()) return [fullPath]
89
+ return []
90
+ })
91
+ }
92
+
93
+ async function runPool (items, concurrency, worker) {
94
+ const results = []
95
+ let nextIndex = 0
96
+
97
+ async function runNext () {
98
+ const index = nextIndex
99
+ nextIndex++
100
+ if (index >= items.length) return
101
+ results[index] = await worker(items[index])
102
+ await runNext()
103
+ }
104
+
105
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, runNext)
106
+ await Promise.all(workers)
107
+ return results
108
+ }
109
+
110
+ function importDeck ({ file, batchId, draftsDir, date }) {
111
+ const roundId = kebab(`${batchId} ${path.basename(file, path.extname(file))}`)
112
+ const title = path.basename(file, path.extname(file))
113
+
114
+ return new Promise((resolve) => {
115
+ const child = spawn(process.execPath, [
116
+ path.join(ctx.package.scriptsDir, 'import-research-deck.js'),
117
+ `--deck=${file}`,
118
+ `--round-id=${roundId}`,
119
+ `--title=${title}`,
120
+ `--date=${date}`,
121
+ `--output-dir=${draftsDir}`
122
+ ], {
123
+ cwd: ctx.root,
124
+ stdio: ['ignore', 'pipe', 'pipe']
125
+ })
126
+
127
+ let stdout = ''
128
+ let stderr = ''
129
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString() })
130
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
131
+
132
+ child.on('close', (code) => {
133
+ const draftPath = path.join(draftsDir, `${roundId}.json`)
134
+ resolve({
135
+ ok: code === 0,
136
+ source: path.relative(ctx.root, file),
137
+ draft: path.relative(ctx.root, draftPath),
138
+ review: path.relative(ctx.root, path.join(draftsDir, `${roundId}.review.md`)),
139
+ stdout: stdout.trim(),
140
+ stderr: stderr.trim()
141
+ })
142
+ })
143
+ })
144
+ }
145
+
146
+ function kebab (value) {
147
+ return value
148
+ .toLowerCase()
149
+ .replace(/[^a-z0-9]+/g, '-')
150
+ .replace(/^-+|-+$/g, '')
151
+ }
152
+
153
+ function camelCase (value) {
154
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
155
+ }
156
+
157
+ main().catch((error) => {
158
+ console.error(error.message)
159
+ process.exit(1)
160
+ })
@@ -0,0 +1,448 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { ResearchImportDraftSchema } = require('../lib/schema')
4
+ const { createContextOrExit } = require('../lib/context')
5
+ const ctx = createContextOrExit()
6
+ const { researchImportsDir } = ctx.paths
7
+ const { extractArtefact, sourceTypeFor } = require('./lib/artefact-text')
8
+
9
+ const defaultOutputDir = researchImportsDir
10
+
11
+ async function main () {
12
+ const args = parseArgs(process.argv.slice(2))
13
+ // Several artefacts can belong to one round, so insight and action IDs are
14
+ // namespaced per artefact while participants stay round-scoped: the same
15
+ // participant code in two files from one round is one person.
16
+ args.artefactId = kebab(args.artefactId || args.roundId || '')
17
+
18
+ if (!args.deck || !args.roundId || !args.title || !args.date) {
19
+ throw new Error('Usage: npm run import:research-deck -- --deck path/to/artefact --round-id round-id [--artefact-id artefact-id] --title "Round title" --date YYYY-MM-DD')
20
+ }
21
+
22
+ const slides = await extractSlides(args.deck)
23
+ const draft = buildDraft(args, slides)
24
+ validateDraft(draft)
25
+
26
+ const outputDir = args.outputDir ? path.resolve(args.outputDir) : defaultOutputDir
27
+ fs.mkdirSync(outputDir, { recursive: true })
28
+ const outputPath = path.join(outputDir, `${args.artefactId}.json`)
29
+ const reviewPath = path.join(outputDir, `${args.artefactId}.review.md`)
30
+ fs.writeFileSync(outputPath, `${JSON.stringify(draft, null, 2)}\n`)
31
+ fs.writeFileSync(reviewPath, buildReviewMarkdown(draft))
32
+
33
+ console.log(`Imported ${slides.length} ${sectionLabel(args.deck).toLowerCase()}`)
34
+ reportFit(draft)
35
+ console.log(`Draft written to ${outputPath}`)
36
+ console.log(`Review written to ${reviewPath}`)
37
+ }
38
+
39
+ function parseArgs (args) {
40
+ const parsed = {}
41
+ for (let index = 0; index < args.length; index++) {
42
+ const arg = args[index]
43
+ if (!arg.startsWith('--')) continue
44
+ const [rawKey, value] = arg.slice(2).split('=', 2)
45
+ const key = camelCase(rawKey)
46
+ if (value !== undefined) {
47
+ parsed[key] = value
48
+ } else {
49
+ parsed[key] = args[index + 1]
50
+ index++
51
+ }
52
+ }
53
+ return parsed
54
+ }
55
+
56
+ function camelCase (value) {
57
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
58
+ }
59
+
60
+ /**
61
+ * Sections come back in one shape whatever the format. Speaker notes are folded
62
+ * into the section text - the notes pane on a playback deck is often where the
63
+ * finding is actually stated, and dropping it lost roughly a tenth of the words
64
+ * in this team's decks.
65
+ */
66
+ async function extractSlides (deckPath) {
67
+ const { sections } = await extractArtefact(deckPath)
68
+ return sections.map((section) => ({
69
+ number: section.number,
70
+ title: section.title,
71
+ text: [...section.text, ...(section.notes || []).map((note) => `Speaker note: ${note}`)]
72
+ }))
73
+ }
74
+
75
+ /**
76
+ * Heading-scoped detection.
77
+ *
78
+ * The first version of this importer only recognised lines literally prefixed
79
+ * "Insight:", "Participant:" or "Action:". No real research deck is written
80
+ * that way, so a 69-slide round-16 playback full of findings imported as zero
81
+ * insights and zero participants.
82
+ *
83
+ * Real artefacts signal intent in the section heading - a slide titled
84
+ * "Insights - Safeguarding", a worksheet column called "Design Action", a
85
+ * persona heading called "Goals" - and then list the content beneath it. So the
86
+ * heading sets a mode, and substantive lines under that heading are captured in
87
+ * that mode. Explicit line prefixes still work, and still win where present.
88
+ */
89
+ const SECTION_MODES = [
90
+ { mode: 'insight', pattern: /\b(insights?|findings?|what we (?:learned|found)|key takeaways?|themes?|pain ?points?|problems?)\b/i },
91
+ { mode: 'action', pattern: /\b(design actions?|actions?|recommendations?|next steps?|opportunit(?:y|ies)|what we(?:'| a)re doing)\b/i },
92
+ { mode: 'participant', pattern: /\b(participants?|who we (?:spoke to|interviewed)|our (?:participants|users)|sample|cohort|the people)\b/i },
93
+ { mode: 'assumption', pattern: /\b(assumptions?|hypothes(?:is|es)|riskiest)\b/i }
94
+ ]
95
+
96
+ const PARTICIPANT_TOKEN = /\bP\d{1,3}\b/g
97
+ const QUOTE = /\u201c([^\u201d]{25,})\u201d/g
98
+
99
+ function buildDraft (args, slides) {
100
+ const participants = []
101
+ const insights = []
102
+ const designActions = []
103
+ const relationships = []
104
+ const seenParticipants = new Map()
105
+ const quotes = []
106
+
107
+ const addParticipant = (label, role) => {
108
+ const key = label.toLowerCase()
109
+ if (seenParticipants.has(key)) return seenParticipants.get(key)
110
+ const item = {
111
+ // A participant code identifies a person, not a person-within-a-round.
112
+ // P36 appears in four rounds and is one participant with four roundIds;
113
+ // namespacing the id by round created a second record for the same person
114
+ // every time a new round mentioned them.
115
+ id: kebab(`participant ${label}`),
116
+ label,
117
+ role: role || 'Research participant',
118
+ roundIds: [args.roundId]
119
+ }
120
+ participants.push(item)
121
+ seenParticipants.set(key, item)
122
+ relationships.push(relationship(`${args.roundId}-has-participant-${item.id}`, args.roundId, 'has-participant', item.id))
123
+ return item
124
+ }
125
+
126
+ const addInsight = (statement, sourceSection, participantIds) => {
127
+ if (!isSubstantive(statement)) return null
128
+ const item = {
129
+ id: kebab(`${args.artefactId} insight ${insights.length + 1}`),
130
+ roundId: args.roundId,
131
+ title: truncate(statement, 120),
132
+ summary: `${statement} (source: ${sourceSection})`,
133
+ confidence: 'medium',
134
+ participantIds: participantIds || [],
135
+ evidenceIds: []
136
+ }
137
+ insights.push(item)
138
+ relationships.push(relationship(`${args.roundId}-produced-insight-${item.id}`, args.roundId, 'produced-insight', item.id))
139
+ return item
140
+ }
141
+
142
+ const addAction = (statement, sourceSection) => {
143
+ if (!isSubstantive(statement)) return null
144
+ const item = {
145
+ id: kebab(`${args.artefactId} action ${designActions.length + 1}`),
146
+ title: truncate(statement, 120),
147
+ status: 'proposed',
148
+ action: statement,
149
+ rationale: `Stated under "${sourceSection}". Confirm against the extracted text before promoting.`,
150
+ insightIds: insights.length ? [insights[insights.length - 1].id] : []
151
+ }
152
+ designActions.push(item)
153
+ if (item.insightIds.length) {
154
+ // The action points at the insight, not the other way round, so evidence
155
+ // impact can walk from research through to the design work it drives.
156
+ relationships.push(relationship(`${args.artefactId}-action-${item.id}`, item.id, 'responds-to-insight', item.insightIds[0]))
157
+ }
158
+ return item
159
+ }
160
+
161
+ for (const slide of slides) {
162
+ const mode = modeFor(slide.title)
163
+
164
+ for (const line of slide.text) {
165
+ // Any participant code mentioned anywhere is a real participant.
166
+ for (const token of line.match(PARTICIPANT_TOKEN) || []) {
167
+ addParticipant(token, roleNear(token, line))
168
+ }
169
+ for (const match of line.matchAll(QUOTE)) {
170
+ quotes.push({ section: slide.title, participants: line.match(PARTICIPANT_TOKEN) || [], text: match[1].trim() })
171
+ }
172
+
173
+ // Explicit prefixes still win.
174
+ const explicitParticipant = parsePrefixedLine(line, ['Participant', 'Attendee'])
175
+ if (explicitParticipant) {
176
+ const [label, role] = explicitParticipant.split(/\s+[\u2013-]\s+/, 2).map((part) => part.trim())
177
+ addParticipant(label || explicitParticipant, role)
178
+ continue
179
+ }
180
+
181
+ const explicitInsight = parsePrefixedLine(line, ['Insight', 'Finding', 'Observation'])
182
+ if (explicitInsight) { addInsight(explicitInsight, slide.title, []); continue }
183
+
184
+ const explicitAction = parsePrefixedLine(line, ['Action', 'Design action', 'Recommendation'])
185
+ if (explicitAction) { addAction(explicitAction, slide.title); continue }
186
+
187
+ // Otherwise fall back to the heading's mode.
188
+ if (line === slide.title || isSpeakerNote(line)) continue
189
+ if (mode === 'insight') addInsight(line, slide.title, (line.match(PARTICIPANT_TOKEN) || []).map((t) => kebab(`${args.roundId} participant ${t}`)))
190
+ else if (mode === 'action') addAction(line, slide.title)
191
+ else if (mode === 'participant' && /\b\d+\s+\w/.test(line)) addInsight(`Sample composition: ${line}`, slide.title, [])
192
+ }
193
+ }
194
+
195
+ // Only keep participant links that point at participants we actually created.
196
+ const participantIds = new Set(participants.map((item) => item.id))
197
+ for (const insight of insights) {
198
+ insight.participantIds = [...new Set(insight.participantIds)].filter((id) => participantIds.has(id))
199
+ }
200
+
201
+ return {
202
+ sourceDeck: path.relative(ctx.root, path.resolve(args.deck)),
203
+ importedAt: new Date().toISOString(),
204
+ warnings: [
205
+ ...assessFit({ participants, insights, designActions, slides, quotes }),
206
+ ...(args.dateDerived === 'false'
207
+ ? [`The round date ${args.date} is today's date, not the date of the research. No date was found in the file or folder name. Set it before promoting.`]
208
+ : [])
209
+ ],
210
+ researchRounds: [
211
+ {
212
+ id: args.roundId,
213
+ title: args.title,
214
+ date: args.date,
215
+ sourceType: sourceType(args.deck),
216
+ sourcePath: path.relative(ctx.root, path.resolve(args.deck)),
217
+ summary: args.summary || `Imported from ${path.basename(args.deck)} (${slides.length} ${sectionLabel(args.deck).toLowerCase()}). A round may span several artefacts.`
218
+ }
219
+ ],
220
+ participants,
221
+ insights,
222
+ designActions,
223
+ relationships,
224
+ slides
225
+ }
226
+ }
227
+
228
+ /** A heading only sets a mode if it reads as a label, not as a sentence. */
229
+ function modeFor (title) {
230
+ if (!title || title.split(/\s+/).length > 12) return null
231
+ const match = SECTION_MODES.find((entry) => entry.pattern.test(title))
232
+ return match ? match.mode : null
233
+ }
234
+
235
+ /**
236
+ * Bullet fragments like "Time-critical" are labels, not findings, and a deck's
237
+ * notes pane wraps mid-sentence, so a line starting lower-case or with stray
238
+ * punctuation is the tail of a thought rather than a statement of one.
239
+ */
240
+ function isSubstantive (line) {
241
+ const words = line.split(/\s+/).filter(Boolean)
242
+ if (words.length < 6 || words.length > 90) return false
243
+ if (/^(agenda|contents|thank you|questions|next slide|any questions)\b/i.test(line)) return false
244
+ if (!/^[A-Z\u201c(]/.test(line)) return false
245
+ if (/^[)\],.;:]/.test(line)) return false
246
+ return /[a-z]/.test(line)
247
+ }
248
+
249
+ /**
250
+ * Speaker notes are kept in the extracted text so a reviewer and the
251
+ * structuring step can read them, but they are commentary on a slide rather
252
+ * than a stated finding, so they do not seed insights on their own.
253
+ */
254
+ function isSpeakerNote (line) {
255
+ return /^Speaker note:/.test(line)
256
+ }
257
+
258
+ /**
259
+ * "P44 - Data Quality Manager" names a role. "P48 - NCA Request for Information -"
260
+ * is the start of a sentence, so anything left dangling on a dash is rejected.
261
+ */
262
+ function roleNear (token, line) {
263
+ const match = line.match(new RegExp(`${token}\\s*[\\u2013-]\\s*([A-Z][^\\u201c.,"]{3,50})`))
264
+ if (!match) return null
265
+ const role = match[1].trim().replace(/[,;\u2013-]+$/, '').trim()
266
+ if (!role || role.split(/\s+/).length > 6) return null
267
+ if (!/[a-z]/.test(role)) return null
268
+ return role
269
+ }
270
+
271
+ function truncate (value, limit) {
272
+ return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`
273
+ }
274
+
275
+ function validateDraft (draft) {
276
+ const result = ResearchImportDraftSchema.safeParse(draft)
277
+ if (result.success) return
278
+
279
+ const issues = result.error.issues.map((issue) => {
280
+ const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
281
+ return `${fieldPath}: ${issue.message}`
282
+ })
283
+ throw new Error(`Imported draft does not match ResearchImportDraftSchema:\n- ${issues.join('\n- ')}`)
284
+ }
285
+
286
+ function assessFit ({ participants, insights, designActions, slides, quotes }) {
287
+ const warnings = []
288
+ if (!participants.length) warnings.push('No participants were detected. Use participant codes such as P12 in the artefact, or lines like "Participant: P1 - Policy administrator".')
289
+ if (!insights.length) warnings.push('No insights were detected. Put findings under a section titled "Insights", "Findings" or "What we learned", or prefix lines with "Insight:".')
290
+ if (!designActions.length) warnings.push('No design actions were detected. Put them under a section titled "Design actions" or "Recommendations", or prefix lines with "Action:".')
291
+ if (slides.length && insights.length / slides.length < 0.2) warnings.push('Low insight density for the number of sections. Check whether findings use unsupported headings.')
292
+ if (quotes && quotes.length && !quotes.some((quote) => quote.participants.length)) {
293
+ warnings.push(`${quotes.length} quotes were found but none carry a participant code. Attribute quotes as 'P12 - "quote"' so evidence can be traced to a person.`)
294
+ }
295
+ if (insights.length > 60) warnings.push('More than 60 insights were detected. The heading-scoped pass is probably capturing narrative text; tighten the draft before promoting.')
296
+ return warnings
297
+ }
298
+
299
+ function reportFit (draft) {
300
+ const warnings = draft.warnings || []
301
+ const fit = warnings.length ? 'needs review' : 'good'
302
+ console.log(`Import fit: ${fit}`)
303
+ if (warnings.length) {
304
+ console.log('Import warnings:')
305
+ for (const warning of warnings) console.log(`- ${warning}`)
306
+ }
307
+ }
308
+
309
+ function buildReviewMarkdown (draft) {
310
+ const round = draft.researchRounds[0]
311
+ const unit = { 'pdf document': 'Page', 'powerpoint deck': 'Slide', 'word document': 'Section', 'word template': 'Section', 'excel workbook': 'Sheet' }[round.sourceType] || 'Section'
312
+ const sectionHeading = `${unit} Text`
313
+ const sectionUnit = `${unit}s`
314
+ const sectionItem = unit
315
+ const lines = [
316
+ `# ${round.title}`,
317
+ '',
318
+ `- **Round ID:** ${round.id}`,
319
+ `- **Date:** ${round.date}`,
320
+ `- **Source:** ${draft.sourceDeck}`,
321
+ `- **Imported:** ${draft.importedAt}`,
322
+ '',
323
+ '## Import Fit',
324
+ ''
325
+ ]
326
+
327
+ if (draft.warnings && draft.warnings.length) {
328
+ lines.push('Needs review.')
329
+ lines.push('')
330
+ for (const warning of draft.warnings) lines.push(`- ${warning}`)
331
+ } else {
332
+ lines.push('Good. No fit warnings detected.')
333
+ }
334
+
335
+ lines.push('')
336
+ lines.push('## Summary')
337
+ lines.push('')
338
+ lines.push(`- Participants: ${draft.participants.length}`)
339
+ lines.push(`- Insights: ${draft.insights.length}`)
340
+ lines.push(`- Design actions: ${draft.designActions.length}`)
341
+ lines.push(`- Relationships: ${draft.relationships.length}`)
342
+ lines.push(`- ${sectionUnit}: ${draft.slides.length}`)
343
+ lines.push('')
344
+
345
+ appendSection(lines, 'Participants', draft.participants, (participant) => [
346
+ `### ${participant.label}`,
347
+ '',
348
+ `- **ID:** ${participant.id}`,
349
+ `- **Role:** ${participant.role}`,
350
+ participant.organisation ? `- **Organisation:** ${participant.organisation}` : null
351
+ ])
352
+
353
+ appendSection(lines, 'Insights', draft.insights, (insight) => [
354
+ `### ${insight.title}`,
355
+ '',
356
+ `- **ID:** ${insight.id}`,
357
+ `- **Confidence:** ${insight.confidence}`,
358
+ '',
359
+ insight.summary
360
+ ])
361
+
362
+ appendSection(lines, 'Design Actions', draft.designActions, (action) => [
363
+ `### ${action.title}`,
364
+ '',
365
+ `- **ID:** ${action.id}`,
366
+ `- **Status:** ${action.status}`,
367
+ '',
368
+ action.action,
369
+ '',
370
+ `**Rationale:** ${action.rationale}`
371
+ ])
372
+
373
+ lines.push(`## ${sectionHeading}`)
374
+ lines.push('')
375
+ for (const slide of draft.slides) {
376
+ lines.push(`### ${sectionItem} ${slide.number}: ${slide.title}`)
377
+ lines.push('')
378
+ if (slide.text.length) {
379
+ for (const text of slide.text) lines.push(`- ${text}`)
380
+ } else {
381
+ lines.push('_No text extracted._')
382
+ }
383
+ lines.push('')
384
+ }
385
+
386
+ return `${lines.filter((line) => line !== null).join('\n')}\n`
387
+ }
388
+
389
+ function appendSection (lines, title, items, renderItem) {
390
+ lines.push(`## ${title}`)
391
+ lines.push('')
392
+ if (!items.length) {
393
+ lines.push('_None detected._')
394
+ lines.push('')
395
+ return
396
+ }
397
+
398
+ for (const item of items) {
399
+ lines.push(...renderItem(item).filter((line) => line !== null))
400
+ lines.push('')
401
+ }
402
+ }
403
+
404
+ function parsePrefixedLine (line, prefixes) {
405
+ for (const prefix of prefixes) {
406
+ const match = line.match(new RegExp(`^${escapeRegExp(prefix)}\\s*[:\\-]\\s*(.+)$`, 'i'))
407
+ if (match) return match[1].trim()
408
+ }
409
+ return null
410
+ }
411
+
412
+ function sourceType (sourcePath) {
413
+ return sourceTypeFor(sourcePath)
414
+ }
415
+
416
+ function sectionLabel (sourcePath) {
417
+ const extension = path.extname(sourcePath).toLowerCase()
418
+ if (extension === '.pdf') return 'PDF pages'
419
+ if (extension === '.pptx') return 'PowerPoint slides'
420
+ if (extension === '.docx' || extension === '.dotx') return 'document sections'
421
+ if (extension === '.xlsx') return 'worksheets'
422
+ return 'sections'
423
+ }
424
+
425
+ function relationship (id, from, type, to) {
426
+ return {
427
+ id: kebab(id),
428
+ from,
429
+ type,
430
+ to
431
+ }
432
+ }
433
+
434
+ function kebab (value) {
435
+ return value
436
+ .toLowerCase()
437
+ .replace(/[^a-z0-9]+/g, '-')
438
+ .replace(/^-+|-+$/g, '')
439
+ }
440
+
441
+ function escapeRegExp (value) {
442
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
443
+ }
444
+
445
+ main().catch((error) => {
446
+ console.error(error.message)
447
+ process.exit(1)
448
+ })