@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,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
|
+
})
|