@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,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Import design actions and decisions from the round tracker workbook.
|
|
4
|
+
*
|
|
5
|
+
* The design process is: analyse insights, agree design actions with the team -
|
|
6
|
+
* which stay deliberately open and can carry several directions - then decide on
|
|
7
|
+
* one, which proceeds into production.
|
|
8
|
+
*
|
|
9
|
+
* The knowledge model already had the shape for that and was not being used for
|
|
10
|
+
* it. A design action is the open brief, with `insightIds` recording what
|
|
11
|
+
* prompted it. Each option weighed against that action is a decision record, and
|
|
12
|
+
* `DecisionSchema` already carries proposed / accepted / rejected / superseded -
|
|
13
|
+
* so the options ledger, and which one won, is expressible without a new record
|
|
14
|
+
* type. What was considered and turned down survives, which is the part that
|
|
15
|
+
* otherwise lives only in someone's memory.
|
|
16
|
+
*
|
|
17
|
+
* node scripts/import-design-actions.js --workbook <path> [--dry-run]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs')
|
|
21
|
+
const path = require('path')
|
|
22
|
+
const { openWorkbook, readSheetGrid } = require('./lib/xlsx-grid')
|
|
23
|
+
const { KnowledgeSchema } = require('../lib/schema')
|
|
24
|
+
const { createContextOrExit } = require('../lib/context')
|
|
25
|
+
const ctx = createContextOrExit()
|
|
26
|
+
const { knowledgeSourceDir } = ctx.paths
|
|
27
|
+
|
|
28
|
+
const ACTION_STATUS = ['proposed', 'in-progress', 'done', 'parked']
|
|
29
|
+
const DECISION_STATUS = ['proposed', 'accepted', 'rejected', 'superseded']
|
|
30
|
+
|
|
31
|
+
// Row 2 of every sheet is a worked example. It is there to show the format, and
|
|
32
|
+
// deleting it is optional, so it is skipped rather than imported.
|
|
33
|
+
const EXAMPLE_IDS = /^(R17-05|R17-10|DA-R17-|DEC-R17-)/i
|
|
34
|
+
|
|
35
|
+
function main () {
|
|
36
|
+
const args = parseArgs(process.argv.slice(2))
|
|
37
|
+
if (!args.workbook) throw new Error('Usage: npm run import:design-actions -- --workbook path/to/tracker.xlsx [--dry-run]')
|
|
38
|
+
|
|
39
|
+
const workbook = openWorkbook(path.resolve(args.workbook))
|
|
40
|
+
const actionRows = rows(workbook, 'Design Actions')
|
|
41
|
+
const decisionRows = rows(workbook, 'Decisions')
|
|
42
|
+
|
|
43
|
+
const read = (f) => JSON.parse(fs.readFileSync(path.join(knowledgeSourceDir, f), 'utf8'))
|
|
44
|
+
const insightIds = new Set(read('insights.json').map((i) => i.id))
|
|
45
|
+
const existingActions = read('design-actions.json')
|
|
46
|
+
const existingDecisions = read('decisions.json')
|
|
47
|
+
const relationships = read('relationships.json')
|
|
48
|
+
|
|
49
|
+
const problems = []
|
|
50
|
+
const actions = []
|
|
51
|
+
const actionIdByTracker = new Map()
|
|
52
|
+
|
|
53
|
+
for (const row of actionRows) {
|
|
54
|
+
const tracker = row['ID']
|
|
55
|
+
if (!tracker || EXAMPLE_IDS.test(tracker)) continue
|
|
56
|
+
const id = kebab(tracker)
|
|
57
|
+
actionIdByTracker.set(tracker.toLowerCase(), id)
|
|
58
|
+
|
|
59
|
+
const status = normalise(row['Status'], ACTION_STATUS)
|
|
60
|
+
if (!status) problems.push(`Design action ${tracker}: status "${row['Status'] || ''}" is not one of ${ACTION_STATUS.join(', ')}`)
|
|
61
|
+
|
|
62
|
+
const linked = splitIds(row['Insight IDs']).map((ref) => resolveInsight(ref, insightIds))
|
|
63
|
+
const missing = splitIds(row['Insight IDs']).filter((ref) => !resolveInsight(ref, insightIds))
|
|
64
|
+
if (missing.length) problems.push(`Design action ${tracker}: insight IDs not found in the knowledge base: ${missing.join(', ')}`)
|
|
65
|
+
if (!linked.filter(Boolean).length) problems.push(`Design action ${tracker}: needs at least one insight ID that exists`)
|
|
66
|
+
|
|
67
|
+
actions.push({
|
|
68
|
+
id,
|
|
69
|
+
title: text(row['Title']) || text(row['Action']).slice(0, 110),
|
|
70
|
+
status: status || 'proposed',
|
|
71
|
+
action: text(row['Action']),
|
|
72
|
+
rationale: [text(row['Rationale']), row['Owner'] && `Owner: ${text(row['Owner'])}.`, row['Dependency'] && `Dependency: ${text(row['Dependency'])}.`]
|
|
73
|
+
.filter(Boolean).join(' ') || `Recorded on tracker row ${tracker}.`,
|
|
74
|
+
insightIds: [...new Set(linked.filter(Boolean))]
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const decisions = []
|
|
79
|
+
for (const row of decisionRows) {
|
|
80
|
+
const tracker = row['ID']
|
|
81
|
+
if (!tracker || EXAMPLE_IDS.test(tracker)) continue
|
|
82
|
+
|
|
83
|
+
const status = normalise(row['Status'], DECISION_STATUS)
|
|
84
|
+
if (!status) problems.push(`Decision ${tracker}: status "${row['Status'] || ''}" is not one of ${DECISION_STATUS.join(', ')}`)
|
|
85
|
+
|
|
86
|
+
const actionRef = text(row['Design action ID'])
|
|
87
|
+
const actionId = actionIdByTracker.get(actionRef.toLowerCase()) ||
|
|
88
|
+
(existingActions.some((a) => a.id === kebab(actionRef)) ? kebab(actionRef) : null)
|
|
89
|
+
if (actionRef && !actionId) problems.push(`Decision ${tracker}: design action "${actionRef}" not found in this workbook or in the knowledge base`)
|
|
90
|
+
|
|
91
|
+
decisions.push({
|
|
92
|
+
record: {
|
|
93
|
+
id: kebab(tracker),
|
|
94
|
+
title: text(row['Option']) || text(row['Decision']).slice(0, 110),
|
|
95
|
+
status: status || 'proposed',
|
|
96
|
+
decision: text(row['Decision']),
|
|
97
|
+
consequence: [text(row['Consequence']), row['Date agreed'] && `Agreed ${text(row['Date agreed'])}.`, row['Decided by'] && `Decided by ${text(row['Decided by'])}.`]
|
|
98
|
+
.filter(Boolean).join(' ') || 'Consequence not yet recorded.'
|
|
99
|
+
},
|
|
100
|
+
actionId
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One accepted option per action, or the ledger is ambiguous.
|
|
105
|
+
const acceptedByAction = {}
|
|
106
|
+
for (const d of decisions) {
|
|
107
|
+
if (d.record.status !== 'accepted' || !d.actionId) continue
|
|
108
|
+
acceptedByAction[d.actionId] = (acceptedByAction[d.actionId] || 0) + 1
|
|
109
|
+
}
|
|
110
|
+
for (const [actionId, count] of Object.entries(acceptedByAction)) {
|
|
111
|
+
if (count > 1) problems.push(`Design action ${actionId}: ${count} decisions are marked accepted. Only one option can be the one that proceeds.`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const list of [[actions, existingActions, 'design action'], [decisions.map((d) => d.record), existingDecisions, 'decision']]) {
|
|
115
|
+
const [incoming, current, label] = list
|
|
116
|
+
const ids = new Set(current.map((x) => x.id))
|
|
117
|
+
for (const item of incoming) if (ids.has(item.id)) problems.push(`${label} ${item.id} already exists in source data`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const links = []
|
|
121
|
+
for (const action of actions) {
|
|
122
|
+
for (const insightId of action.insightIds) {
|
|
123
|
+
links.push({ id: `rel-${action.id}-responds-to-${insightId}`, from: action.id, type: 'responds-to-insight', to: insightId })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const d of decisions) {
|
|
127
|
+
if (!d.actionId) continue
|
|
128
|
+
links.push({ id: `rel-${d.record.id}-resolves-${d.actionId}`, from: d.record.id, type: 'resolves-action', to: d.actionId })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`Design actions: ${actions.length} (${ACTION_STATUS.map((s) => `${s} ${actions.filter((a) => a.status === s).length}`).join(', ')})`)
|
|
132
|
+
console.log(`Decisions: ${decisions.length} (${DECISION_STATUS.map((s) => `${s} ${decisions.filter((d) => d.record.status === s).length}`).join(', ')})`)
|
|
133
|
+
console.log(`Relationships: ${links.length}`)
|
|
134
|
+
|
|
135
|
+
if (problems.length) {
|
|
136
|
+
console.error(`\n${problems.length} problem${problems.length === 1 ? '' : 's'} to fix in the workbook:`)
|
|
137
|
+
for (const p of problems) console.error(`- ${p}`)
|
|
138
|
+
process.exit(1)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (args.dryRun) {
|
|
142
|
+
console.log('\nDry run. Source files were not changed.')
|
|
143
|
+
for (const a of actions) {
|
|
144
|
+
console.log(`\n ${a.id} [${a.status}] from ${a.insightIds.length} insight(s)`)
|
|
145
|
+
console.log(` ${a.action.slice(0, 120)}`)
|
|
146
|
+
for (const d of decisions.filter((x) => x.actionId === a.id)) {
|
|
147
|
+
console.log(` - ${d.record.status.padEnd(10)} ${d.record.title.slice(0, 90)}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const proposed = {
|
|
154
|
+
'design-actions.json': [...existingActions, ...actions],
|
|
155
|
+
'decisions.json': [...existingDecisions, ...decisions.map((d) => d.record)],
|
|
156
|
+
'relationships.json': [...new Map([...relationships, ...links].map((r) => [r.id, r])).values()]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate the whole knowledge base before writing, not after, so a bad
|
|
160
|
+
// workbook cannot leave source data broken.
|
|
161
|
+
// Files beginning with _ are the knowledge base's own metadata, not one of its
|
|
162
|
+
// collections - _meta.json carries the schema version. KnowledgeSchema is
|
|
163
|
+
// strict, so feeding it one fails the parse and blocks every import.
|
|
164
|
+
const preview = Object.fromEntries(fs.readdirSync(knowledgeSourceDir)
|
|
165
|
+
.filter((f) => f.endsWith('.json') && !f.startsWith('_'))
|
|
166
|
+
.map((f) => [key(f), proposed[f] || JSON.parse(fs.readFileSync(path.join(knowledgeSourceDir, f), 'utf8'))]))
|
|
167
|
+
const result = KnowledgeSchema.safeParse(preview)
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
console.error('\nThe import would make the knowledge base invalid:')
|
|
170
|
+
for (const issue of result.error.issues.slice(0, 10)) console.error(`- ${issue.path.join('.')}: ${issue.message}`)
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const [file, data] of Object.entries(proposed)) {
|
|
175
|
+
fs.writeFileSync(path.join(knowledgeSourceDir, file), `${JSON.stringify(data, null, 2)}\n`)
|
|
176
|
+
}
|
|
177
|
+
console.log(`\nImported from ${path.relative(ctx.root, path.resolve(args.workbook))}. Run npm run validate:knowledge to confirm.`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function rows (workbook, sheetName) {
|
|
181
|
+
if (!workbook.sheetFiles.has(sheetName)) {
|
|
182
|
+
throw new Error(`The workbook has no "${sheetName}" sheet. It has: ${[...workbook.sheetFiles.keys()].join(', ')}. Start from hippocampus/templates/ncrs-insight-design-action-tracker.xlsx.`)
|
|
183
|
+
}
|
|
184
|
+
const grid = readSheetGrid(workbook, sheetName)
|
|
185
|
+
const header = grid.find((r) => r && r.some(Boolean)) || []
|
|
186
|
+
const headerRow = grid.indexOf(header)
|
|
187
|
+
const out = []
|
|
188
|
+
for (let i = headerRow + 1; i < grid.length; i++) {
|
|
189
|
+
const row = grid[i]
|
|
190
|
+
if (!row || !row.some(Boolean)) continue
|
|
191
|
+
out.push(Object.fromEntries(header.map((h, c) => [String(h || '').trim(), row[c] || ''])))
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const text = (v) => String(v ?? '').replace(/\s+/g, ' ').trim()
|
|
197
|
+
const splitIds = (v) => text(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean)
|
|
198
|
+
|
|
199
|
+
/** Tracker IDs are written R16-15; insight IDs are round-16-insight-15. */
|
|
200
|
+
function resolveInsight (ref, insightIds) {
|
|
201
|
+
const direct = kebab(ref)
|
|
202
|
+
if (insightIds.has(direct)) return direct
|
|
203
|
+
const m = ref.match(/^R-?\s*(\d+)\s*-\s*(.+)$/i)
|
|
204
|
+
if (m) {
|
|
205
|
+
const candidate = `round-${m[1]}-insight-${kebab(m[2])}`
|
|
206
|
+
if (insightIds.has(candidate)) return candidate
|
|
207
|
+
}
|
|
208
|
+
return [...insightIds].find((id) => id.endsWith(`-${kebab(ref)}`)) || null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalise (value, allowed) {
|
|
212
|
+
const v = text(value).toLowerCase().replace(/\s+/g, '-')
|
|
213
|
+
return allowed.includes(v) ? v : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const key = (file) => file.replace('.json', '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
217
|
+
const kebab = (v) => text(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
218
|
+
|
|
219
|
+
function parseArgs (argv) {
|
|
220
|
+
const parsed = {}
|
|
221
|
+
for (let i = 0; i < argv.length; i++) {
|
|
222
|
+
if (!argv[i].startsWith('--')) continue
|
|
223
|
+
const [k, v] = argv[i].slice(2).split('=', 2)
|
|
224
|
+
const name = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
225
|
+
parsed[name] = v !== undefined ? v : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true)
|
|
226
|
+
}
|
|
227
|
+
return parsed
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
main()
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const AdmZip = require('adm-zip')
|
|
4
|
+
const { PDFParse } = require('pdf-parse')
|
|
5
|
+
const { XMLParser } = require('fast-xml-parser')
|
|
6
|
+
const { ProjectContextImportDraftSchema } = require('../lib/schema')
|
|
7
|
+
const { createContextOrExit } = require('../lib/context')
|
|
8
|
+
const ctx = createContextOrExit()
|
|
9
|
+
const { projectImportsDir } = ctx.paths
|
|
10
|
+
|
|
11
|
+
async function main () {
|
|
12
|
+
const args = parseArgs(process.argv.slice(2))
|
|
13
|
+
if (!args.document || !args.contextId || !args.title) {
|
|
14
|
+
throw new Error('Usage: npm run import:project-context -- --document path/to/overview.pdf --context-id context-id --title "Project overview"')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sections = await extractSections(args.document)
|
|
18
|
+
const draft = buildDraft(args, sections)
|
|
19
|
+
validateDraft(draft)
|
|
20
|
+
|
|
21
|
+
const outputDir = args.outputDir ? path.resolve(args.outputDir) : projectImportsDir
|
|
22
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
23
|
+
|
|
24
|
+
const outputPath = path.join(outputDir, `${args.contextId}.json`)
|
|
25
|
+
const reviewPath = path.join(outputDir, `${args.contextId}.review.md`)
|
|
26
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(draft, null, 2)}\n`)
|
|
27
|
+
fs.writeFileSync(reviewPath, buildReviewMarkdown(draft))
|
|
28
|
+
|
|
29
|
+
console.log(`Imported ${sections.length} ${sectionLabel(args.document).toLowerCase()}`)
|
|
30
|
+
reportFit(draft)
|
|
31
|
+
console.log(`Draft written to ${outputPath}`)
|
|
32
|
+
console.log(`Review written to ${reviewPath}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs (args) {
|
|
36
|
+
const parsed = {}
|
|
37
|
+
for (let index = 0; index < args.length; index++) {
|
|
38
|
+
const arg = args[index]
|
|
39
|
+
if (!arg.startsWith('--')) continue
|
|
40
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
41
|
+
const key = camelCase(rawKey)
|
|
42
|
+
if (value !== undefined) {
|
|
43
|
+
parsed[key] = value
|
|
44
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
45
|
+
parsed[key] = args[index + 1]
|
|
46
|
+
index++
|
|
47
|
+
} else {
|
|
48
|
+
parsed[key] = true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parsed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function extractSections (documentPath) {
|
|
55
|
+
const extension = path.extname(documentPath).toLowerCase()
|
|
56
|
+
if (extension === '.pptx') return extractPowerPointSlides(documentPath)
|
|
57
|
+
if (extension === '.pdf') return extractPdfPages(documentPath)
|
|
58
|
+
throw new Error(`Unsupported project context artefact extension ${extension || '(none)'}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractPowerPointSlides (deckPath) {
|
|
62
|
+
const zip = new AdmZip(deckPath)
|
|
63
|
+
const parser = new XMLParser({
|
|
64
|
+
ignoreAttributes: false,
|
|
65
|
+
removeNSPrefix: true
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return zip.getEntries()
|
|
69
|
+
.filter((entry) => /^ppt\/slides\/slide\d+\.xml$/.test(entry.entryName))
|
|
70
|
+
.sort((a, b) => slideNumber(a.entryName) - slideNumber(b.entryName))
|
|
71
|
+
.map((entry) => {
|
|
72
|
+
const xml = entry.getData().toString('utf8')
|
|
73
|
+
const parsed = parser.parse(xml)
|
|
74
|
+
const text = collectText(parsed).map(normaliseText).filter(Boolean)
|
|
75
|
+
return {
|
|
76
|
+
number: slideNumber(entry.entryName),
|
|
77
|
+
title: text[0] || `Slide ${slideNumber(entry.entryName)}`,
|
|
78
|
+
text
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function extractPdfPages (pdfPath) {
|
|
84
|
+
const parser = new PDFParse({ data: fs.readFileSync(pdfPath) })
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await parser.getText({ pageJoiner: '' })
|
|
88
|
+
return result.pages.map((page) => {
|
|
89
|
+
const text = page.text.split(/\n+/).map(normaliseText).filter(Boolean)
|
|
90
|
+
return {
|
|
91
|
+
number: page.num,
|
|
92
|
+
title: text[0] || `Page ${page.num}`,
|
|
93
|
+
text
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
} finally {
|
|
97
|
+
await parser.destroy()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildDraft (args, sections) {
|
|
102
|
+
const sourceDocument = path.relative(ctx.root, path.resolve(args.document))
|
|
103
|
+
const lines = sections.flatMap((section) => section.text)
|
|
104
|
+
const context = {
|
|
105
|
+
id: args.contextId,
|
|
106
|
+
title: args.title,
|
|
107
|
+
summary: args.summary || firstUsefulLine(lines) || `Imported project context from ${path.basename(args.document)}.`,
|
|
108
|
+
purpose: extractPurpose(lines),
|
|
109
|
+
vision: extractStartingAt(lines, 'Our vision'),
|
|
110
|
+
problemStatement: extractStartingAt(lines, 'Users find'),
|
|
111
|
+
serviceProposition: extractServiceProposition(lines),
|
|
112
|
+
users: extractUsers(lines),
|
|
113
|
+
channels: extractChannels(lines),
|
|
114
|
+
policies: extractPolicies(lines),
|
|
115
|
+
constraints: extractConstraints(lines),
|
|
116
|
+
risks: extractRisks(lines),
|
|
117
|
+
milestones: extractMilestones(lines),
|
|
118
|
+
facts: extractFacts(lines),
|
|
119
|
+
sourceArtefacts: [
|
|
120
|
+
{
|
|
121
|
+
id: kebab(`${args.contextId} source`),
|
|
122
|
+
title: args.title,
|
|
123
|
+
sourcePath: sourceDocument,
|
|
124
|
+
sourceType: sourceType(args.document),
|
|
125
|
+
importedAt: new Date().toISOString()
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
sourceDocument,
|
|
132
|
+
importedAt: new Date().toISOString(),
|
|
133
|
+
warnings: assessFit(context),
|
|
134
|
+
projectContext: context,
|
|
135
|
+
sections
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractPurpose (lines) {
|
|
140
|
+
const purposeLines = lines.filter((line) => {
|
|
141
|
+
const lower = line.toLowerCase()
|
|
142
|
+
return lower.includes('statutory obligation') ||
|
|
143
|
+
lower.includes('dhsc are responsible') ||
|
|
144
|
+
lower.includes('digital service provided by dhsc')
|
|
145
|
+
})
|
|
146
|
+
return sentence(purposeLines)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function extractServiceProposition (lines) {
|
|
150
|
+
const propositionLines = lines.filter((line) => {
|
|
151
|
+
const lower = line.toLowerCase()
|
|
152
|
+
return lower.includes('similar but enhanced service') ||
|
|
153
|
+
lower.includes('robust, fast, reliable') ||
|
|
154
|
+
lower.includes('user friendly webform') ||
|
|
155
|
+
lower.includes('audit trail') ||
|
|
156
|
+
lower.includes('case management')
|
|
157
|
+
})
|
|
158
|
+
return sentence(propositionLines)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractStartingAt (lines, prefix) {
|
|
162
|
+
const index = lines.findIndex((line) => line.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
163
|
+
if (index === -1) return undefined
|
|
164
|
+
return sentence(lines.slice(index, index + 3))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function extractUsers (lines) {
|
|
168
|
+
const start = lines.findIndex((line) => /this service is for/i.test(line))
|
|
169
|
+
const end = lines.findIndex((line, index) => index > start && /under the terms/i.test(line))
|
|
170
|
+
const userLines = start === -1 ? [] : lines.slice(start + 1, end === -1 ? start + 30 : end)
|
|
171
|
+
return unique(userLines.filter(isUsefulUserLine).map((line) => ({
|
|
172
|
+
id: kebab(line),
|
|
173
|
+
label: line,
|
|
174
|
+
group: userGroup(line)
|
|
175
|
+
})))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractChannels (lines) {
|
|
179
|
+
return ['API', 'Paper', 'Webform'].flatMap((label) => {
|
|
180
|
+
const index = lines.findIndex((line) => line.toLowerCase() === label.toLowerCase())
|
|
181
|
+
if (index === -1) return []
|
|
182
|
+
const detail = lines.slice(index + 1, index + 4).filter((line) => !['API', 'Paper', 'Webform'].includes(line)).join(' ')
|
|
183
|
+
const usage = lines.find((line) => line.toLowerCase().includes(`${label.toLowerCase()},`)) ||
|
|
184
|
+
lines.find((line) => line.toLowerCase().includes(`${label.toLowerCase()} `) && line.includes('%'))
|
|
185
|
+
return [{
|
|
186
|
+
id: kebab(label),
|
|
187
|
+
label,
|
|
188
|
+
description: detail || undefined,
|
|
189
|
+
usage
|
|
190
|
+
}]
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractPolicies (lines) {
|
|
195
|
+
return unique(lines.filter((line) => {
|
|
196
|
+
const lower = line.toLowerCase()
|
|
197
|
+
return lower.includes('abortion act') ||
|
|
198
|
+
lower.includes('abortion regulations') ||
|
|
199
|
+
lower.includes('amendment regulations') ||
|
|
200
|
+
lower.includes('legal requirement') ||
|
|
201
|
+
lower.includes('statutory obligation')
|
|
202
|
+
}).map((line) => ({
|
|
203
|
+
id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
|
|
204
|
+
title: line,
|
|
205
|
+
summary: line
|
|
206
|
+
})))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractConstraints (lines) {
|
|
210
|
+
return unique(lines.filter((line) => {
|
|
211
|
+
const lower = line.toLowerCase()
|
|
212
|
+
return lower.includes('contract') ||
|
|
213
|
+
lower.includes('cannot') ||
|
|
214
|
+
lower.includes('not engaged') ||
|
|
215
|
+
lower.includes('legal requirement')
|
|
216
|
+
}).map((line) => ({
|
|
217
|
+
id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
|
|
218
|
+
label: line,
|
|
219
|
+
detail: line
|
|
220
|
+
})))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractRisks (lines) {
|
|
224
|
+
return unique(lines.filter((line) => {
|
|
225
|
+
const lower = line.toLowerCase()
|
|
226
|
+
return lower.includes('risk') ||
|
|
227
|
+
lower.includes('low engagement') ||
|
|
228
|
+
lower.includes('low tech maturity') ||
|
|
229
|
+
lower.includes('unreliable') ||
|
|
230
|
+
lower.includes('prone to errors')
|
|
231
|
+
}).map((line) => ({
|
|
232
|
+
id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
|
|
233
|
+
label: line,
|
|
234
|
+
detail: line,
|
|
235
|
+
severity: lowerSeverity(line)
|
|
236
|
+
})))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function extractMilestones (lines) {
|
|
240
|
+
return unique(lines.filter((line) => {
|
|
241
|
+
const lower = line.toLowerCase()
|
|
242
|
+
return lower.includes('may 2027') ||
|
|
243
|
+
lower.includes('alpha') ||
|
|
244
|
+
lower.includes('re-procure')
|
|
245
|
+
}).map((line) => ({
|
|
246
|
+
id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
|
|
247
|
+
label: line,
|
|
248
|
+
detail: line
|
|
249
|
+
})))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function extractFacts (lines) {
|
|
253
|
+
const facts = []
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
if (/\d/.test(line) && (line.includes('%') || /users|forms|submissions/i.test(line))) {
|
|
256
|
+
facts.push({
|
|
257
|
+
id: kebab(line).slice(0, 80).replace(/-+$/g, ''),
|
|
258
|
+
label: line,
|
|
259
|
+
value: line
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return unique(facts)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assessFit (context) {
|
|
267
|
+
const warnings = []
|
|
268
|
+
if (!context.purpose) warnings.push('No clear purpose was detected. Add one before promotion if the overview contains it.')
|
|
269
|
+
if (!context.problemStatement) warnings.push('No problem statement was detected. Review the source text and add it if present.')
|
|
270
|
+
if (!context.users.length) warnings.push('No project-level users were detected.')
|
|
271
|
+
if (!context.channels.length) warnings.push('No service channels were detected.')
|
|
272
|
+
if (!context.policies.length) warnings.push('No policy or regulation context was detected.')
|
|
273
|
+
return warnings
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateDraft (draft) {
|
|
277
|
+
const result = ProjectContextImportDraftSchema.safeParse(draft)
|
|
278
|
+
if (result.success) return
|
|
279
|
+
|
|
280
|
+
const issues = result.error.issues.map((issue) => {
|
|
281
|
+
const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
|
|
282
|
+
return `${fieldPath}: ${issue.message}`
|
|
283
|
+
})
|
|
284
|
+
throw new Error(`Imported draft does not match ProjectContextImportDraftSchema:\n- ${issues.join('\n- ')}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function reportFit (draft) {
|
|
288
|
+
const warnings = draft.warnings || []
|
|
289
|
+
const fit = warnings.length ? 'needs review' : 'good'
|
|
290
|
+
console.log(`Import fit: ${fit}`)
|
|
291
|
+
if (warnings.length) {
|
|
292
|
+
console.log('Import warnings:')
|
|
293
|
+
for (const warning of warnings) console.log(`- ${warning}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildReviewMarkdown (draft) {
|
|
298
|
+
const context = draft.projectContext
|
|
299
|
+
const lines = [
|
|
300
|
+
`# ${context.title}`,
|
|
301
|
+
'',
|
|
302
|
+
`- **Context ID:** ${context.id}`,
|
|
303
|
+
`- **Source:** ${draft.sourceDocument}`,
|
|
304
|
+
`- **Imported:** ${draft.importedAt}`,
|
|
305
|
+
'',
|
|
306
|
+
'## Import Fit',
|
|
307
|
+
''
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
if (draft.warnings && draft.warnings.length) {
|
|
311
|
+
lines.push('Needs review.')
|
|
312
|
+
lines.push('')
|
|
313
|
+
for (const warning of draft.warnings) lines.push(`- ${warning}`)
|
|
314
|
+
} else {
|
|
315
|
+
lines.push('Good. No fit warnings detected.')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
lines.push('')
|
|
319
|
+
lines.push('## Overview')
|
|
320
|
+
lines.push('')
|
|
321
|
+
if (context.purpose) lines.push(`- **Purpose:** ${context.purpose}`)
|
|
322
|
+
if (context.vision) lines.push(`- **Vision:** ${context.vision}`)
|
|
323
|
+
if (context.problemStatement) lines.push(`- **Problem statement:** ${context.problemStatement}`)
|
|
324
|
+
if (context.serviceProposition) lines.push(`- **Service proposition:** ${context.serviceProposition}`)
|
|
325
|
+
lines.push('')
|
|
326
|
+
lines.push('## Counts')
|
|
327
|
+
lines.push('')
|
|
328
|
+
lines.push(`- Users: ${context.users.length}`)
|
|
329
|
+
lines.push(`- Channels: ${context.channels.length}`)
|
|
330
|
+
lines.push(`- Policies: ${context.policies.length}`)
|
|
331
|
+
lines.push(`- Constraints: ${context.constraints.length}`)
|
|
332
|
+
lines.push(`- Risks: ${context.risks.length}`)
|
|
333
|
+
lines.push(`- Milestones: ${context.milestones.length}`)
|
|
334
|
+
lines.push(`- Facts: ${context.facts.length}`)
|
|
335
|
+
lines.push('')
|
|
336
|
+
lines.push('## Extracted Text')
|
|
337
|
+
lines.push('')
|
|
338
|
+
|
|
339
|
+
for (const section of draft.sections) {
|
|
340
|
+
lines.push(`### ${section.title}`)
|
|
341
|
+
lines.push('')
|
|
342
|
+
for (const text of section.text) lines.push(`- ${text}`)
|
|
343
|
+
lines.push('')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return `${lines.join('\n')}\n`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function slideNumber (entryName) {
|
|
350
|
+
return Number(entryName.match(/slide(\d+)\.xml$/)[1])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function collectText (value, includePrimitive = false) {
|
|
354
|
+
if (value === null || value === undefined) return []
|
|
355
|
+
if (typeof value === 'string' || typeof value === 'number') return includePrimitive ? [String(value)] : []
|
|
356
|
+
if (Array.isArray(value)) return value.flatMap((child) => collectText(child, includePrimitive))
|
|
357
|
+
if (typeof value !== 'object') return []
|
|
358
|
+
|
|
359
|
+
const text = []
|
|
360
|
+
for (const [key, child] of Object.entries(value)) {
|
|
361
|
+
if (key === 't') text.push(...collectText(child, true))
|
|
362
|
+
else text.push(...collectText(child, false))
|
|
363
|
+
}
|
|
364
|
+
return text
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isUsefulUserLine (line) {
|
|
368
|
+
if (!line || line.length < 3) return false
|
|
369
|
+
if (/^\d/.test(line)) return false
|
|
370
|
+
if (/purpose|problem|vision|channels|usage/i.test(line)) return false
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function userGroup (line) {
|
|
375
|
+
if (/dhsc|analyst|researcher|data entry|medical advisor/i.test(line)) return 'Internal'
|
|
376
|
+
if (/healthcare|policy|education|media|police/i.test(line)) return 'Data consumer'
|
|
377
|
+
return 'External'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function lowerSeverity (line) {
|
|
381
|
+
if (/low engagement|low tech|risk/i.test(line)) return 'medium'
|
|
382
|
+
return undefined
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function firstUsefulLine (lines) {
|
|
386
|
+
return lines.find((line) => line.length > 30)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sentence (lines) {
|
|
390
|
+
const value = lines.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
|
391
|
+
return value || undefined
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sourceType (filePath) {
|
|
395
|
+
const extension = path.extname(filePath).toLowerCase()
|
|
396
|
+
if (extension === '.pdf') return 'pdf document'
|
|
397
|
+
if (extension === '.pptx') return 'presentation deck'
|
|
398
|
+
return extension.replace('.', '') || 'document'
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sectionLabel (filePath) {
|
|
402
|
+
return path.extname(filePath).toLowerCase() === '.pdf' ? 'pages' : 'slides'
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function normaliseText (value) {
|
|
406
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function unique (items) {
|
|
410
|
+
return Object.values(Object.fromEntries(items.map((item) => [item.id, item])))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function kebab (value) {
|
|
414
|
+
return value
|
|
415
|
+
.toLowerCase()
|
|
416
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
417
|
+
.replace(/^-+|-+$/g, '')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function camelCase (value) {
|
|
421
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
main().catch((error) => {
|
|
425
|
+
console.error(error.message)
|
|
426
|
+
process.exit(1)
|
|
427
|
+
})
|