The Atlas doc.haus documentation, bound to its code
180 documents

Zero to a running demo

From a fresh clone to asking a fictional engagement letter about its liability cap — the fastest way to feel what doc.haus is.

services/ingest/src/seed.ts347 lines · CLAUSES L79–144
Outline 15 symbols
1import {
2 Document,
3 Packer,
4 Paragraph,
5 TextRun,
6 ImageRun,
7 AlignmentType,
8 BorderStyle,
9 Footer,
10 PageNumber,
11 Table,
12 TableRow,
13 TableCell,
14 WidthType,
15} from "docx"
16import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"
17import path from "node:path"
18import { createMatter, listMatters, WORKSPACE_ROOT } from "./matter"
19import { ingestDocument } from "./ingest"
20import { seedTemplates } from "./template"
21
22// Two seeds live here, with different lifetimes:
23//
24// seedPlaybooks() — the repo-shipped starter playbook library, copied into the
25// firm's WORKSPACE_ROOT/.playbooks on every ingest server boot (server.ts calls
26// it on startup). Not demo content: every install gets the starter set.
27//
28// seedDemo() — the demo matter: a wholly fictional letter of engagement,
29// ingested through the real pipeline so a first-time user lands on a matter
30// that already answers cited questions and runs a legal review — no upload, no
31// data of their own required. Demo-gated: it only runs via this script
32// (`start.sh --demo` / `cd services/ingest && bun run seed`).
33//
34// seedDemo also writes the generated .docx to repo `demo/` so the same file can
35// be dropped into a new matter through the web UI. Everything in it is invented;
36// Aldgate & Crane LLP and Aldgate Mills Limited do not exist.
37
38// dochaus/playbooks/ holds the repo-shipped starter playbooks — kept out of
39// dochaus/skill/ so the engine and listPlaybooks() never auto-discover them from
40// the repo; the firm's selectable library is WORKSPACE_ROOT/.playbooks, the same
41// directory POST /playbooks imports into. Each starter is copied in at most once,
42// tracked by name in the .seeded marker file: a starter the firm deleted stays
43// deleted (delete-playbook is first-class), edits and same-name imports are never
44// clobbered, and newly shipped starters still reach existing installs.
45const PLAYBOOKS_SRC = path.join(import.meta.dir, "..", "..", "..", "dochaus", "playbooks")
46
47export function seedPlaybooks() {
48 const playbooksDir = path.join(WORKSPACE_ROOT, ".playbooks")
49 const marker = path.join(playbooksDir, ".seeded")
50 const seeded = new Set(existsSync(marker) ? readFileSync(marker, "utf8").split("\n").filter(Boolean) : [])
51 const fresh = readdirSync(PLAYBOOKS_SRC)
52 .filter((name) => name.startsWith("playbook-"))
53 .filter((name) => existsSync(path.join(PLAYBOOKS_SRC, name, "SKILL.md")))
54 .filter((name) => !seeded.has(name))
55 if (!fresh.length) return
56 fresh
57 // A same-name import already present wins — record it as seeded without copying.
58 .filter((name) => !existsSync(path.join(playbooksDir, name, "SKILL.md")))
59 .forEach((name) => {
60 mkdirSync(path.join(playbooksDir, name), { recursive: true })
61 copyFileSync(path.join(PLAYBOOKS_SRC, name, "SKILL.md"), path.join(playbooksDir, name, "SKILL.md"))
62 console.log(`Seeded starter playbook "${name}"`)
63 })
64 mkdirSync(playbooksDir, { recursive: true })
65 writeFileSync(marker, [...seeded, ...fresh].join("\n") + "\n")
66}
67
68const DOC_NAME = "Letter of Engagement — Aldgate Mills.docx"
69const MATTER_TITLE = "Aldgate Mills — Engagement (Demo)"
70
71// The demo letter is an E&W law-firm engagement letter, so the matter binds the
72// matching starter playbook — the full review pipeline (reviewer, playbook,
73// challenger, summarizer) then demos end-to-end on first run.
74const DEMO_PLAYBOOK = "playbook-engagement-letter"
75
76// Each clause is a bold "N. Title" heading paragraph followed by its body. The
77// ingest sectionizer keys sections off the leading clause number, so a citation
78// like [Letter of Engagement — Aldgate Mills § 9] resolves to clause 9.
79const CLAUSES: [string, string][] = [
80 [
81 "1. Scope of our work",
82 "We will advise on and complete the acquisition of the long leasehold of Unit 5, Saffron Wharf, London E1, including reviewing the agreement for lease and the lease, reporting to you on title and on the principal commercial terms, raising and reviewing enquiries, and dealing with completion and post-completion registration at HM Land Registry. We will not advise on the commercial merits of the transaction, on tax beyond Stamp Duty Land Tax, or on the physical condition of the property, which are outside the scope of this engagement unless separately agreed in writing.",
83 ],
84 [
85 "2. The people acting for you",
86 "Your matter will be handled by Daniel Crane, Partner (charged at £480 per hour), with support from Priya Nair, Associate (charged at £290 per hour). Routine work may be delegated to a trainee or paralegal at £160 per hour where that is cost-effective. We will tell you promptly if the person responsible for your matter changes.",
87 ],
88 [
89 "3. Our fees",
90 "Our fees are calculated principally by reference to the time spent at the hourly rates in clause 2. Our current estimate for this matter is £14,500 plus VAT and disbursements. An estimate is not a fixed quotation; if it becomes likely that the estimate will be exceeded, we will tell you before further significant costs are incurred and agree a revised estimate with you.",
91 ],
92 [
93 "4. Disbursements",
94 "Disbursements are expenses we pay on your behalf, such as Land Registry fees, search fees, and Stamp Duty Land Tax. We will normally ask you to put us in funds for substantial disbursements before we incur them.",
95 ],
96 [
97 "5. Billing and payment",
98 "We will deliver interim bills monthly as the matter progresses, with a final bill on completion. Each bill is payable within 14 days of its date. We reserve the right to charge interest on bills not paid within that period at 4% per year above the base rate of the Bank of England, calculated from the date of the bill until payment.",
99 ],
100 [
101 "6. Money on account",
102 "We ask you to pay £5,000 on account of our fees and disbursements before we begin substantive work. We will hold that money in our client account and apply it against our bills, asking you to top it up as the matter proceeds.",
103 ],
104 [
105 "7. Your responsibilities",
106 "You agree to give us clear and timely instructions, to provide the documents and information we reasonably request, to put us in funds as agreed, and to tell us promptly of any change to the transaction or your instructions. Delay in any of these may affect our estimate and the timetable.",
107 ],
108 [
109 "8. Confidentiality",
110 "We will keep your affairs confidential, save where disclosure is required by law or by our regulator, or where you authorise it. Our duty of confidentiality continues after this engagement ends.",
111 ],
112 [
113 "9. Limitation of liability",
114 "Our total aggregate liability to you arising out of or in connection with this engagement, whether in contract, tort (including negligence), breach of statutory duty or otherwise, is limited to £3 million, which corresponds to our professional indemnity insurance cover. We are not liable for any indirect or consequential loss, or for loss of profit, revenue, or anticipated savings. Nothing in this clause limits any liability that cannot lawfully be limited, including liability for death or personal injury caused by negligence or for fraud.",
115 ],
116 [
117 "10. Conflicts of interest",
118 "We have checked for conflicts of interest and are not aware of any that prevent us acting for you. If a conflict arises during the engagement, we will tell you and explain the options, which may include our ceasing to act.",
119 ],
120 [
121 "11. Data protection",
122 "We process your personal data as a controller in order to act for you, in accordance with the UK GDPR and the Data Protection Act 2018. We retain your file for seven years after the matter closes, after which we may destroy it without further reference to you. Our privacy notice gives further detail.",
123 ],
124 [
125 "12. Termination",
126 "You may end this engagement at any time by written notice. We may cease to act only for good reason, such as a conflict of interest, non-payment of our bills, or your failure to give instructions, and on giving you reasonable written notice. On termination you remain liable for our fees and disbursements incurred up to that point.",
127 ],
128 [
129 "13. Complaints",
130 "We aim to provide a high standard of service. If you are unhappy with our service or a bill, please raise it first with Daniel Crane. If we cannot resolve it, you may be entitled to complain to the Legal Ombudsman, normally within six months of our final response. You may also have the right to challenge a bill under the Solicitors Act 1974.",
131 ],
132 [
133 "14. Regulation",
134 "Aldgate & Crane LLP is authorised and regulated by the Solicitors Regulation Authority. We are bound by the SRA Standards and Regulations, which are available from the SRA.",
135 ],
136 [
137 "15. Governing law",
138 "This engagement and our terms of business are governed by the law of England and Wales, and the courts of England and Wales have exclusive jurisdiction over any dispute arising out of them.",
139 ],
140 [
141 "16. Acceptance",
142 "If these terms are acceptable, please sign and date below and return one copy to us. Work you ask us to carry out, or the payment of money on account, will in any event be taken as your acceptance of these terms.",
143 ],
144]
145
146// House palette for the (fictional) firm's letterhead.
147const NAVY = "1C2B3A"
148const GOLD = "C9A24B"
149const INK = "222222"
150
151// 22 half-points = 11pt body; clause/heading sizes follow. Rules are drawn as
152// bottom paragraph borders so they print without a table.
153const RULE = { bottom: { style: BorderStyle.SINGLE, size: 6, space: 4, color: GOLD } }
154
155function body(text: string) {
156 return new Paragraph({
157 alignment: AlignmentType.JUSTIFIED,
158 spacing: { after: 160, line: 276 },
159 children: [new TextRun(text)],
160 })
161}
162
163function clause([title, text]: [string, string]) {
164 const [num, ...rest] = title.split(". ")
165 return [
166 new Paragraph({
167 spacing: { before: 220, after: 60 },
168 children: [
169 new TextRun({ text: `${num}.`, bold: true, color: GOLD }),
170 new TextRun({ text: ` ${rest.join(". ")}`, bold: true, color: NAVY, allCaps: true, size: 20 }),
171 ],
172 }),
173 body(text),
174 ]
175}
176
177// Two-column block: recipient on the left, our ref / date on the right. A
178// borderless table keeps the columns aligned the way a real letter sets them.
179function metaCell(lines: string[], alignment: (typeof AlignmentType)[keyof typeof AlignmentType]) {
180 return new TableCell({
181 width: { size: 50, type: WidthType.PERCENTAGE },
182 margins: { top: 0, bottom: 0, left: 0, right: 0 },
183 children: lines.map(
184 (text, i) =>
185 new Paragraph({
186 alignment,
187 spacing: { after: 20 },
188 children: [new TextRun({ text, bold: i === 0, color: INK, size: 19 })],
189 }),
190 ),
191 })
192}
193
194const NO_BORDERS = {
195 top: { style: BorderStyle.NONE, size: 0, color: "auto" },
196 bottom: { style: BorderStyle.NONE, size: 0, color: "auto" },
197 left: { style: BorderStyle.NONE, size: 0, color: "auto" },
198 right: { style: BorderStyle.NONE, size: 0, color: "auto" },
199 insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "auto" },
200 insideVertical: { style: BorderStyle.NONE, size: 0, color: "auto" },
201}
202
203export async function seedDemo() {
204 // The firm's emblem — a serif "A&C" monogram in a gold-ruled navy square. A
205 // committed static asset, read at run time so the .docx carries a real
206 // embedded image with no image-processing dependency.
207 const logoPng = await Bun.file(path.join(import.meta.dir, "..", "assets", "logo.png")).bytes()
208
209 const doc = new Document({
210 styles: { default: { document: { run: { font: "Georgia", size: 22, color: INK } } } },
211 sections: [
212 {
213 properties: { page: { margin: { top: 1100, bottom: 1100, left: 1300, right: 1300 } } },
214 footers: {
215 default: new Footer({
216 children: [
217 new Paragraph({
218 border: { top: { style: BorderStyle.SINGLE, size: 4, space: 6, color: GOLD } },
219 alignment: AlignmentType.CENTER,
220 spacing: { before: 60 },
221 children: [
222 new TextRun({
223 text: "Aldgate & Crane LLP — a limited liability partnership registered in England and Wales (OC384726). ",
224 size: 14,
225 color: "888888",
226 }),
227 new TextRun({ text: "Authorised and regulated by the Solicitors Regulation Authority.", size: 14, color: "888888" }),
228 ],
229 }),
230 new Paragraph({
231 alignment: AlignmentType.CENTER,
232 children: [new TextRun({ children: ["Page ", PageNumber.CURRENT, " of ", PageNumber.TOTAL_PAGES], size: 14, color: "888888" })],
233 }),
234 ],
235 }),
236 },
237 children: [
238 new Paragraph({
239 alignment: AlignmentType.CENTER,
240 spacing: { after: 40 },
241 children: [new ImageRun({ data: logoPng, type: "png", transformation: { width: 76, height: 76 } })],
242 }),
243 new Paragraph({
244 alignment: AlignmentType.CENTER,
245 spacing: { after: 20 },
246 children: [new TextRun({ text: "ALDGATE & CRANE LLP", bold: true, color: NAVY, size: 30, allCaps: true })],
247 }),
248 new Paragraph({
249 alignment: AlignmentType.CENTER,
250 spacing: { after: 120 },
251 border: RULE,
252 children: [new TextRun({ text: "S O L I C I T O R S", color: GOLD, size: 16 })],
253 }),
254 new Paragraph({
255 alignment: AlignmentType.CENTER,
256 spacing: { after: 240 },
257 children: [
258 new TextRun({ text: "14 Saffron Court, London EC3N 4QX", size: 18, color: "555555" }),
259 new TextRun({ text: " · +44 (0)20 7946 0042 · law@aldgatecrane.co.uk", size: 18, color: "555555" }),
260 ],
261 }),
262 new Table({
263 width: { size: 100, type: WidthType.PERCENTAGE },
264 borders: NO_BORDERS,
265 rows: [
266 new TableRow({
267 children: [
268 metaCell(
269 ["Aldgate Mills Limited", "FAO: Ms R. Okafor, Director", "27 Wharf Road", "London E1 8GW"],
270 AlignmentType.LEFT,
271 ),
272 metaCell(["Our ref: A&C/2026-0042", "3 June 2026", "By email and post"], AlignmentType.RIGHT),
273 ],
274 }),
275 ],
276 }),
277 new Paragraph({
278 alignment: AlignmentType.CENTER,
279 spacing: { before: 240, after: 160 },
280 children: [new TextRun({ text: "LETTER OF ENGAGEMENT", bold: true, color: NAVY, size: 26, allCaps: true })],
281 }),
282 body("Dear Ms Okafor,"),
283 new Paragraph({
284 alignment: AlignmentType.JUSTIFIED,
285 spacing: { after: 160, line: 276 },
286 children: [
287 new TextRun({ text: "Re: Proposed acquisition of the long leasehold of Unit 5, Saffron Wharf, London E1. ", bold: true }),
288 new TextRun(
289 "Thank you for instructing Aldgate & Crane LLP. This letter sets out the basis on which we will act for you. Please read it, and let us know if anything is unclear, before signing and returning the acceptance at the end.",
290 ),
291 ],
292 }),
293 ...CLAUSES.flatMap(clause),
294 new Paragraph({ spacing: { before: 240, after: 160 }, border: RULE, children: [] }),
295 body("Yours sincerely,"),
296 new Paragraph({
297 spacing: { before: 200, after: 40 },
298 children: [new TextRun({ text: "Daniel Crane", bold: true, color: NAVY })],
299 }),
300 body("Partner, for and on behalf of Aldgate & Crane LLP"),
301 new Paragraph({
302 spacing: { before: 240, after: 40 },
303 children: [new TextRun({ text: "Signed (client): ____________________________ Date: ______________", color: INK })],
304 }),
305 body("Ms R. Okafor, for and on behalf of Aldgate Mills Limited"),
306 ],
307 },
308 ],
309 })
310
311 const buffer = await Packer.toBuffer(doc)
312
313 // Keep a copy in repo demo/ so the same .docx can be uploaded through the UI.
314 const demoDir = path.join(import.meta.dir, "..", "..", "..", "demo")
315 mkdirSync(demoDir, { recursive: true })
316 writeFileSync(path.join(demoDir, "Letter-of-Engagement-Aldgate-Mills.docx"), buffer)
317
318 // Seed the firm's template library from repo demo/templates. Demo-only: a non-demo
319 // boot ships an empty template library so first-run users start with nothing seeded.
320 seedTemplates()
321
322 // Idempotent: `start.sh --demo` runs this on every boot, so skip ingestion if the
323 // demo matter is already present rather than piling up duplicates. The .docx above
324 // is still rewritten so demo/ stays in sync with the seed script. The playbook is
325 // bound only at creation — an existing matter's binding (including a deliberate
326 // unbinding) is left alone.
327 const existing = listMatters().find((m) => m.title === MATTER_TITLE)
328 if (existing) {
329 console.log(`Demo matter "${MATTER_TITLE}" already present (${existing.id}); skipping ingest.`)
330 return
331 }
332
333 // The demo letter is an England & Wales engagement (SRA-regulated firm, UK GDPR,
334 // E&W governing-law clause), so seed it with the EW jurisdiction pack — the first
335 // run then shows jurisdiction-aware reasoning without any setup.
336 const matter = createMatter(MATTER_TITLE, "A&C/2026-0042", ["EW"], DEMO_PLAYBOOK)
337 const result = await ingestDocument(matter.dir, DOC_NAME, Buffer.from(buffer))
338 console.log(`Seeded matter "${matter.title}" (${matter.id}), bound to ${DEMO_PLAYBOOK}`)
339 console.log(`Ingested ${DOC_NAME}: ${result.sections} sections, ${result.chunks} chunks`)
340 console.log(`Open the web app and select "${MATTER_TITLE}" to try cited Q&A and a legal review.`)
341}
342
343if (import.meta.main) {
344 seedPlaybooks()
345 await seedDemo()
346}
347