@stratta/mcp 1.18.0 → 1.18.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/README.md CHANGED
@@ -9,8 +9,9 @@ mcp-name: ch.stratta/mcp
9
9
 
10
10
  # @stratta/mcp
11
11
 
12
- The local MCP server for [Stratta](https://stratta.ch): the engineering norms
13
- your bureau licensed (SIA, Eurocodes), cited to the section and page, inside
12
+ The local MCP server for [Stratta](https://stratta.ch): the norms and reference
13
+ documents your organization deposited (technical standards, laws, regulations,
14
+ accounting standards, internal references), cited to the section and page, inside
14
15
  any agent that speaks MCP.
15
16
 
16
17
  > Requires a free Stratta account: https://stratta.ch/sign-up.
@@ -233,7 +234,7 @@ package announces 59 of them; the remote connector serves 61: everything minus
233
234
  | `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
234
235
  | `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
235
236
  | `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
236
- | `record_decision` | Settle a question with a decision the engineer has confirmed, and the retained option. |
237
+ | `record_decision` | Settle a question with a decision the user has confirmed, and the retained option. |
237
238
  | `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
238
239
  | `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
239
240
  | `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets. |
@@ -277,7 +278,7 @@ of the organisation. A report template learned from an office Word file on
277
278
  [stratta.ch/bibliotheque](https://stratta.ch/bibliotheque) is served as a skill
278
279
  too, and its file is the layout the Word export is rendered into.
279
280
 
280
- **Ingest** (10 tools — add YOUR licensed norms; driven by the bundled `ingest-norm` skill; owner or admin role):
281
+ **Ingest** (10 tools — add YOUR documents; driven by the bundled `ingest-norm` skill; owner or admin role):
281
282
 
282
283
  | Tool | Purpose |
283
284
  | ----------------------------- | -------------------------------------------------------------------------------- |
@@ -336,7 +337,7 @@ Three prompts appear as slash commands in clients that support them:
336
337
  `investigate-question` (question, project?), `resume-dossier` (name) and
337
338
  `check-value` (value, norm?).
338
339
 
339
- For ingesting your own licensed norms, see the bundled **`ingest-norm` skill** —
340
+ For depositing your own documents, see the bundled **`ingest-norm` skill** —
340
341
  a 2-phase hybrid pipeline (since 0.4.0): a Python pre-pass
341
342
  (`scripts/ingest-prepass.py`, PyMuPDF) extracts the hierarchical tree and
342
343
  rasterizes figures deterministically, then the agent enriches sections with
@@ -13,7 +13,7 @@ export type CatalogEntry = {
13
13
  transports: Transport[];
14
14
  params: Record<string, string>;
15
15
  };
16
- export declare const SERVER_INSTRUCTIONS = "Stratta serves the construction norms of the signed-in organization, and only those. Call `get_methodology` before the first technical question of a conversation: it carries the navigation order, the citation rules and the dossier policy. Never answer a technical question from memory; a norm absent from `list_norms` cannot be read.";
16
+ export declare const SERVER_INSTRUCTIONS = "Stratta serves the norms and reference documents of the signed-in organization (technical standards, laws, ordinances, regulations, accounting standards, internal references), and only those. Call `get_methodology` before the first substantive question of a conversation: it carries the navigation order, the citation rules and the dossier policy. Never answer from memory a question these documents are meant to settle; a norm absent from `list_norms` cannot be read.";
17
17
  export declare const CATALOG: CatalogEntry[];
18
18
  export type PromptArg = {
19
19
  name: string;
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/build-mcp-catalog.mjs from convex/mcpCatalog.ts.
2
2
  // Do not edit: change the source and run the script.
3
- export const SERVER_INSTRUCTIONS = "Stratta serves the construction norms of the signed-in organization, and only those. Call `get_methodology` before the first technical question of a conversation: it carries the navigation order, the citation rules and the dossier policy. Never answer a technical question from memory; a norm absent from `list_norms` cannot be read.";
3
+ export const SERVER_INSTRUCTIONS = "Stratta serves the norms and reference documents of the signed-in organization (technical standards, laws, ordinances, regulations, accounting standards, internal references), and only those. Call `get_methodology` before the first substantive question of a conversation: it carries the navigation order, the citation rules and the dossier policy. Never answer from memory a question these documents are meant to settle; a norm absent from `list_norms` cannot be read.";
4
4
  export const CATALOG = [
5
5
  {
6
6
  "name": "get_methodology",
@@ -37,7 +37,7 @@ export const CATALOG = [
37
37
  {
38
38
  "name": "list_norms",
39
39
  "title": "Available norms",
40
- "description": "List the norms published in the signed-in organization, with code, year, title, language and coverage (how much of the norm was ingested). This is the first call of any search: a norm absent from this list cannot be read and must not be answered from memory. A demo entry is fictional and never a technical reference; a sparse entry carries a notice to relay.",
40
+ "description": "List the norms and reference documents (standards, laws, regulations) published in the signed-in organization, with code, year, title, language and coverage (how much of the norm was ingested). This is the first call of any search: a norm absent from this list cannot be read and must not be answered from memory. A demo entry is fictional and never a technical reference; a sparse entry carries a notice to relay.",
41
41
  "annotations": {
42
42
  "readOnlyHint": true,
43
43
  "destructiveHint": false,
@@ -65,7 +65,7 @@ export const CATALOG = [
65
65
  "remote"
66
66
  ],
67
67
  "params": {
68
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
68
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
69
69
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
70
70
  "maxDepth": "Nesting depth to include. 1 (default) returns chapters only; 2 adds X.Y sections. Large norms grow fast beyond 2."
71
71
  }
@@ -85,7 +85,7 @@ export const CATALOG = [
85
85
  "remote"
86
86
  ],
87
87
  "params": {
88
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
88
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
89
89
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
90
90
  "path": "Path to root the subtree at, e.g. \"9\" or \"9.5\". A clause number below the last heading resolves to that heading.",
91
91
  "maxDepth": "Depth relative to the root. 1 returns the root and its direct children; omit for the whole subtree.",
@@ -107,7 +107,7 @@ export const CATALOG = [
107
107
  "remote"
108
108
  ],
109
109
  "params": {
110
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
110
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
111
111
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
112
112
  "path": "Section path as it appears in the table of contents, e.g. \"4.2.1\" or \"Annexe A\".",
113
113
  "maxChars": "Characters of text per call, 500 to 40000. Default 12000, which fits any client. Raise it only when one section is needed whole.",
@@ -117,7 +117,7 @@ export const CATALOG = [
117
117
  {
118
118
  "name": "search_in_norm",
119
119
  "title": "Search a norm",
120
- "description": "Full-text search inside one norm, ranked, a hit in a heading outranking a hit in the body. Use it when the table of contents suggests no obvious section. Matching is per term, not per substring, in the language of the norm: search \"charges\" in a French norm, not \"loads\"; accents and case do not matter. A clause number as keyword returns that clause. Returns paths, titles and snippets; read the promising ones with get_section.",
120
+ "description": "Full-text search inside one norm, ranked, a hit in a heading outranking a hit in the body. Use it when the table of contents suggests no obvious section. Matching is per term, not per substring, in the language of the norm: search \"charges\" in a French document, not \"loads\"; accents and case do not matter. A clause number as keyword returns that clause. Returns paths, titles and snippets; read the promising ones with get_section.",
121
121
  "annotations": {
122
122
  "readOnlyHint": true,
123
123
  "destructiveHint": false,
@@ -129,7 +129,7 @@ export const CATALOG = [
129
129
  "remote"
130
130
  ],
131
131
  "params": {
132
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
132
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
133
133
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
134
134
  "keyword": "Search terms in the language of the norm, accents optional. Several words find sections containing all of them; a clause number finds that clause.",
135
135
  "limit": "Maximum number of hits, 1 to 50. Default 20."
@@ -157,7 +157,7 @@ export const CATALOG = [
157
157
  {
158
158
  "name": "get_cross_refs",
159
159
  "title": "Cross-references",
160
- "description": "Return the references one section makes: to other clauses, annexes, figures and tables of the same norm, and to other norms. SIA norms deliberately spread the rule, the coefficient and the action across separate clauses and norms, so following a reference is often what separates a correct answer from an incomplete one. Each reference says whether it is resolvable in this corpus (listed first) and names the target section; read each relevant target with get_section.",
160
+ "description": "Return the references one section makes: to other clauses, annexes, figures and tables of the same norm, and to other norms. Norms and regulations often spread the rule, the coefficient and the exception across separate clauses and documents, so following a reference is often what separates a correct answer from an incomplete one. Each reference says whether it is resolvable in this corpus (listed first) and names the target section; read each relevant target with get_section.",
161
161
  "annotations": {
162
162
  "readOnlyHint": true,
163
163
  "destructiveHint": false,
@@ -169,7 +169,7 @@ export const CATALOG = [
169
169
  "remote"
170
170
  ],
171
171
  "params": {
172
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
172
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
173
173
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
174
174
  "path": "Section path as it appears in the table of contents, e.g. \"4.2.1\" or \"Annexe A\"."
175
175
  }
@@ -177,7 +177,7 @@ export const CATALOG = [
177
177
  {
178
178
  "name": "get_figure",
179
179
  "title": "Figure",
180
- "description": "Return one figure of a norm as an image: load diagrams, design charts, standard cross-sections. Call it when the figure carries the rule and describing it in words would lose the content. The figure identifier comes from the figures array of get_section.",
180
+ "description": "Return one figure of a norm as an image: diagrams, charts, standard cross-sections. Call it when the figure carries the rule and describing it in words would lose the content. The figure identifier comes from the figures array of get_section.",
181
181
  "annotations": {
182
182
  "readOnlyHint": true,
183
183
  "destructiveHint": false,
@@ -189,7 +189,7 @@ export const CATALOG = [
189
189
  "remote"
190
190
  ],
191
191
  "params": {
192
- "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"SIA 267\", \"EN 1997-1\".",
192
+ "code": "Norm code exactly as list_norms returns it, e.g. \"SIA 261\", \"EN 1997-1\", \"CO\", \"IFRS 15\".",
193
193
  "edition": "Edition of the norm when the organization holds several (\"2013\", \"2020\"). Default: the most recent published edition; list_norms names them.",
194
194
  "figureId": "Figure identifier from the figures array of get_section."
195
195
  }
@@ -488,7 +488,7 @@ export const CATALOG = [
488
488
  {
489
489
  "name": "save_finding",
490
490
  "title": "Record evidence",
491
- "description": "Record one piece of evidence in a dossier, as you work rather than in a batch at the end. Kinds: reference (what a norm says; always give normCode, sectionPath and page, or it is not a reference), hypothesis (the value the engineer retains and why, value in value and reasoning in detail), observation (what the site, a borehole or a survey showed, with the date). One clause, one value or one fact per call, in one or two sentences. Pass questionId to file it under the question it serves; without it the entry waits unclassified. Set confidence whenever a value is at stake: established, judgement or to_confirm. Questions go through open_question and decisions through record_decision, not here.",
491
+ "description": "Record one piece of evidence in a dossier, as you work rather than in a batch at the end. Kinds: reference (what a norm says; always give normCode, sectionPath and page, or it is not a reference), hypothesis (the value the user retains and why, value in value and reasoning in detail), observation (what the site, a borehole or a survey showed, with the date). One clause, one value or one fact per call, in one or two sentences. Pass questionId to file it under the question it serves; without it the entry waits unclassified. Set confidence whenever a value is at stake: established, judgement or to_confirm. Questions go through open_question and decisions through record_decision, not here.",
492
492
  "annotations": {
493
493
  "readOnlyHint": false,
494
494
  "destructiveHint": false,
@@ -503,10 +503,10 @@ export const CATALOG = [
503
503
  "dossierId": "Dossier id returned by open_dossier or list_dossiers.",
504
504
  "questionId": "The question this evidence serves, from open_question or load_dossier. Omit only when you do not know yet.",
505
505
  "kind": "reference, hypothesis or observation: pick by what the entry is.",
506
- "title": "The fact in one line, as an engineer would write it in a report. One clause, one value or one observation; not a theme.",
506
+ "title": "The fact in one line, as a professional would write it in a report. One clause, one value or one observation; not a theme.",
507
507
  "detail": "The reasoning in one or two sentences: why this value, what it rests on, what disagreed. Expected for a hypothesis.",
508
508
  "value": "The retained value with its unit, e.g. \"30 deg\", \"1.25 kN/m2\".",
509
- "confidence": "established (computed or read directly), judgement (the engineer chose it), to_confirm (provisional, pending a test).",
509
+ "confidence": "established (computed or read directly), judgement (the user chose it), to_confirm (provisional, pending a test).",
510
510
  "normCode": "Norm this comes from, e.g. \"SIA 267\".",
511
511
  "sectionPath": "Exact section path, e.g. \"9.5.2.1\".",
512
512
  "page": "Page in the norm, so a human can verify it in their PDF.",
@@ -518,7 +518,7 @@ export const CATALOG = [
518
518
  {
519
519
  "name": "record_decision",
520
520
  "title": "Record a decision",
521
- "description": "Record what the engineer retained: the decision in one line, why, and the clause it rests on. Pass questionId when the question already exists; otherwise pass dossierId and question (what is settled, in one line) and the question is opened on the spot, already decided. Name the retained option when the question listed some; put the number in value and set confidence when the decision is a value. Only after the user confirmed the decision, never on your own reasoning. Returns entryId.",
521
+ "description": "Record what the user retained: the decision in one line, why, and the clause it rests on. Pass questionId when the question already exists; otherwise pass dossierId and question (what is settled, in one line) and the question is opened on the spot, already decided. Name the retained option when the question listed some; put the number in value and set confidence when the decision is a value. Only after the user confirmed the decision, never on your own reasoning. Returns entryId.",
522
522
  "annotations": {
523
523
  "readOnlyHint": false,
524
524
  "destructiveHint": false,
@@ -587,7 +587,7 @@ export const CATALOG = [
587
587
  {
588
588
  "name": "list_attachments",
589
589
  "title": "Project attachments",
590
- "description": "List the project attachments of one dossier: the site reports, borehole logs, meeting minutes and data sheets the engineer uploaded. Returns for each its id, name, type, page count and extraction status. Read one with read_attachment before answering on the project; a piece is evidence like a clause and is cited the same way.",
590
+ "description": "List the project attachments of one dossier: the site reports, borehole logs, meeting minutes and data sheets the user uploaded. Returns for each its id, name, type, page count and extraction status. Read one with read_attachment before answering on the project; a piece is evidence like a clause and is cited the same way.",
591
591
  "annotations": {
592
592
  "readOnlyHint": true,
593
593
  "destructiveHint": false,
@@ -664,7 +664,7 @@ export const CATALOG = [
664
664
  {
665
665
  "name": "list_templates",
666
666
  "title": "Dossier templates",
667
- "description": "List the checklists the organization wrote for its types of structure: each is a set of questions with the clauses they rest on. When a new project matches one, apply it with apply_template instead of opening the questions one by one.",
667
+ "description": "List the checklists the organization wrote for its types of case: each is a set of questions with the clauses they rest on. When a new project matches one, apply it with apply_template instead of opening the questions one by one.",
668
668
  "annotations": {
669
669
  "readOnlyHint": true,
670
670
  "destructiveHint": false,
@@ -989,7 +989,7 @@ export const CATALOG = [
989
989
  ],
990
990
  "params": {
991
991
  "entryId": "Entry id from get_question or load_dossier.",
992
- "title": "The fact in one line, as an engineer would write it in a report.",
992
+ "title": "The fact in one line, as a professional would write it in a report.",
993
993
  "detail": "The reasoning in one or two sentences.",
994
994
  "value": "The retained value with its unit, e.g. \"30 deg\".",
995
995
  "confidence": "established, judgement or to_confirm.",
@@ -1040,7 +1040,7 @@ export const CATALOG = [
1040
1040
  {
1041
1041
  "name": "ingest_create_document",
1042
1042
  "title": "Create norm document",
1043
- "description": "Create a draft norm document in the workspace. Returns the documentId every later ingest_* call needs. The organization must hold a licence for the norm it ingests.",
1043
+ "description": "Create a draft norm document in the workspace. Returns the documentId every later ingest_* call needs. The organization must hold the rights to the document it deposits.",
1044
1044
  "annotations": {
1045
1045
  "readOnlyHint": false,
1046
1046
  "destructiveHint": false,
@@ -1058,7 +1058,8 @@ export const CATALOG = [
1058
1058
  "language": "fr, de, it or en.",
1059
1059
  "totalPages": "Page count of the source PDF.",
1060
1060
  "edition": "Edition label as printed on the cover (\"2013\", \"2020+C1\"). Default: the year. Lets several editions of one code coexist.",
1061
- "scope": "One sentence on what the norm covers, shown in the served methodology, e.g. \"Geotechnical design: foundations, piles, anchors, retaining structures\"."
1061
+ "scope": "One sentence on what the norm covers, shown in the served methodology, e.g. \"Geotechnical design: foundations, piles, anchors, retaining structures\".",
1062
+ "licence": "Watermark and copyright lines of the licensed copy, verbatim, as the pre-pass wrote them in doc.licence. Shown with every excerpt of the norm."
1062
1063
  }
1063
1064
  },
1064
1065
  {
@@ -1226,12 +1227,12 @@ export const CATALOG = [
1226
1227
  export const PROMPTS = [
1227
1228
  {
1228
1229
  "name": "investigate-question",
1229
- "title": "Investigate a question in the norms",
1230
- "description": "Answer a technical question from the norms of this workspace, with a citation for every value, and file what is retained in the project dossier when there is one.",
1230
+ "title": "Investigate a question in the documents",
1231
+ "description": "Answer a question from the norms and reference documents of this workspace, with a citation for every value, and file what is retained in the project dossier when there is one.",
1231
1232
  "args": [
1232
1233
  {
1233
1234
  "name": "question",
1234
- "description": "The engineering question, as the user would ask a colleague.",
1235
+ "description": "The question, as the user would ask a colleague.",
1235
1236
  "required": true
1236
1237
  },
1237
1238
  {
@@ -15,6 +15,7 @@ export declare const ingestCreateDocument: import("./define.js").ToolDef<{
15
15
  totalPages: z.ZodNumber;
16
16
  scope: z.ZodOptional<z.ZodString>;
17
17
  edition: z.ZodOptional<z.ZodString>;
18
+ licence: z.ZodOptional<z.ZodString>;
18
19
  }>;
19
20
  export declare const ingestCreateSections: import("./define.js").ToolDef<{
20
21
  documentId: z.ZodString;
@@ -94,6 +95,7 @@ export declare const ingestTools: (import("./define.js").ToolDef<{
94
95
  totalPages: z.ZodNumber;
95
96
  scope: z.ZodOptional<z.ZodString>;
96
97
  edition: z.ZodOptional<z.ZodString>;
98
+ licence: z.ZodOptional<z.ZodString>;
97
99
  }> | import("./define.js").ToolDef<{
98
100
  documentId: z.ZodString;
99
101
  sections: z.ZodArray<z.ZodObject<{
@@ -91,6 +91,11 @@ export const ingestCreateDocument = defineTool({
91
91
  .max(20)
92
92
  .optional()
93
93
  .describe(param('ingest_create_document', 'edition')),
94
+ licence: z
95
+ .string()
96
+ .max(600)
97
+ .optional()
98
+ .describe(param('ingest_create_document', 'licence')),
94
99
  },
95
100
  run: async (client, args) => ({
96
101
  documentId: await client.action(api.ingestionApi.createDocument, {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stratta/mcp",
3
3
  "mcpName": "ch.stratta/mcp",
4
- "version": "1.18.0",
5
- "description": "MCP server exposing the engineering norms your firm is licensed for (SIA / Eurocodes) to any MCP client, via Stratta TreeRAG.",
4
+ "version": "1.18.1",
5
+ "description": "MCP server exposing your organization’s norms and reference documents (standards, laws, regulations) to any MCP client, with exact citations, via Stratta TreeRAG.",
6
6
  "license": "UNLICENSED",
7
7
  "author": "SmartFlow <hello@stratta.ch>",
8
8
  "homepage": "https://stratta.ch",
@@ -28,7 +28,10 @@ from collections import defaultdict
28
28
  from pathlib import Path
29
29
 
30
30
  try:
31
- import fitz # PyMuPDF
31
+ try:
32
+ import pymupdf
33
+ except ImportError: # PyMuPDF < 1.24.3 only knows the legacy name
34
+ import fitz as pymupdf
32
35
  except ImportError:
33
36
  sys.stderr.write(
34
37
  "ERROR: PyMuPDF not installed. Run: pip install --user pymupdf\n"
@@ -81,7 +84,7 @@ OCR_DOTTED_NUMBER_RE = re.compile(r"^(\d+(?:\.\d+)*) \.(\d)")
81
84
  VERTICAL_WATERMARK_SHARE = 0.25
82
85
 
83
86
 
84
- def _vertical_lines(page: "fitz.Page") -> list[tuple[tuple[float, float, float, float], set[str]]]:
87
+ def _vertical_lines(page: "pymupdf.Page") -> list[tuple[tuple[float, float, float, float], set[str]]]:
85
88
  """Box and words of each line not written left to right.
86
89
 
87
90
  Matched to the words by position and text, never by (block, line)
@@ -117,7 +120,7 @@ def _in_vertical_line(
117
120
  return False
118
121
 
119
122
 
120
- def _rows(page: "fitz.Page") -> list[list[tuple]]:
123
+ def _rows(page: "pymupdf.Page") -> list[list[tuple]]:
121
124
  words = page.get_text("words") # x0, y0, x1, y1, text, block, line, word
122
125
  if not words:
123
126
  return []
@@ -177,7 +180,7 @@ def _two_column(rows: list[list[list[tuple]]]) -> bool:
177
180
  return both / len(rows) > 0.3
178
181
 
179
182
 
180
- def page_lines(doc: "fitz.Document", pageno: int) -> list[str]:
183
+ def page_lines(doc: "pymupdf.Document", pageno: int) -> list[str]:
181
184
  """The lines of a page, in reading order, numbers joined to their text."""
182
185
  key = (id(doc), pageno)
183
186
  cached = _LINES_CACHE.get(key)
@@ -200,11 +203,11 @@ def page_lines(doc: "fitz.Document", pageno: int) -> list[str]:
200
203
  return lines
201
204
 
202
205
 
203
- def page_text(doc: "fitz.Document", pageno: int) -> str:
206
+ def page_text(doc: "pymupdf.Document", pageno: int) -> str:
204
207
  return "\n".join(page_lines(doc, pageno))
205
208
 
206
209
 
207
- def detect_toc_pages(doc: "fitz.Document") -> set[int]:
210
+ def detect_toc_pages(doc: "pymupdf.Document") -> set[int]:
208
211
  out = set()
209
212
  for i in range(doc.page_count):
210
213
  lines = [l for l in page_lines(doc, i) if l.strip()]
@@ -232,7 +235,7 @@ def furniture_key(line: str) -> str:
232
235
  return FURNITURE_EDGE_RE.sub("", s).strip()
233
236
 
234
237
 
235
- def detect_running_text(doc: "fitz.Document") -> set[str]:
238
+ def detect_running_text(doc: "pymupdf.Document") -> set[str]:
236
239
  """Lines appearing on >=5 pages near the top/bottom (running headers/footers),
237
240
  keyed without their page number."""
238
241
  counter: dict[str, int] = defaultdict(int)
@@ -243,6 +246,60 @@ def detect_running_text(doc: "fitz.Document") -> set[str]:
243
246
  return {l for l, c in counter.items() if c >= 5 and len(l) > 8}
244
247
 
245
248
 
249
+ LICENCE_LINE_RE = re.compile(r"copyright|©|licen[cs]|lizenz|tous droits|alle rechte", re.I)
250
+ LICENCE_MAX_CHARS = 600
251
+
252
+
253
+ def detect_licence(doc: "pymupdf.Document", running: set[str]) -> str:
254
+ """The licence marks of the copy: watermark and copyright footer, once.
255
+
256
+ The page text leaves them out (they would land on every line they cross),
257
+ but they are never thrown away: the norm keeps them in its `licence`
258
+ field and every excerpt served from it carries them.
259
+ - a vertical line that is a thin minority of its page and recurs on two
260
+ pages at least (the library watermark up the margin);
261
+ - a running header or footer that names a copyright or a licence.
262
+ """
263
+ seen: dict[str, int] = {}
264
+ for i in range(doc.page_count):
265
+ page = doc[i]
266
+ vertical = _vertical_lines(page)
267
+ if not vertical:
268
+ continue
269
+ total = len(page.get_text("words")) or 1
270
+ share = sum(len(words) for _, words in vertical) / total
271
+ if share > VERTICAL_WATERMARK_SHARE:
272
+ continue
273
+ texts: list[str] = []
274
+ for block in page.get_text("dict").get("blocks", []):
275
+ for line in block.get("lines", []):
276
+ dx, dy = line.get("dir", (1.0, 0.0))
277
+ if abs(dy) <= 0.1 and dx >= 0:
278
+ continue
279
+ text = re.sub(
280
+ r"\s+", " ", " ".join(s.get("text", "") for s in line.get("spans", []))
281
+ ).strip()
282
+ if len(text) > 3:
283
+ texts.append(text)
284
+ for text in dict.fromkeys(texts):
285
+ seen[text] = seen.get(text, 0) + 1
286
+ marks = [t for t, count in seen.items() if count >= 2]
287
+ # `running` holds the footers keyed without their numbers ("SIA , Copyright
288
+ # by SIA Zurich"): the mark is the line as printed, page number aside.
289
+ keys = {l for l in running if LICENCE_LINE_RE.search(l)}
290
+ for i in range(doc.page_count):
291
+ if not keys:
292
+ break
293
+ lines = [l.strip() for l in page_lines(doc, i) if l.strip()]
294
+ for line in lines[:3] + lines[-3:]:
295
+ key = furniture_key(line)
296
+ if key in keys:
297
+ keys.discard(key)
298
+ marks.append(re.sub(r"^\d{1,4}\s+|\s+\d{1,4}$", "", line))
299
+ licence = "\n".join(dict.fromkeys(marks))
300
+ return licence[:LICENCE_MAX_CHARS].rstrip()
301
+
302
+
246
303
  CHAP_UPPER_RE = re.compile(
247
304
  r"^(\d+)\s+([A-ZÉÈÀÂÔÎÛÇ][^a-z\n]{3,120})\s*$", re.MULTILINE
248
305
  )
@@ -292,7 +349,7 @@ def join_split_headings(text: str) -> str:
292
349
  return "\n".join(out)
293
350
 
294
351
 
295
- def heading_text(doc: "fitz.Document", pageno: int) -> str:
352
+ def heading_text(doc: "pymupdf.Document", pageno: int) -> str:
296
353
  """Page text prepared for heading detection."""
297
354
  return join_split_headings(page_text(doc, pageno))
298
355
 
@@ -329,7 +386,7 @@ def plausible_title(title: str) -> bool:
329
386
  return letters / len(t) >= 0.7
330
387
 
331
388
 
332
- def dense_heading_pages(doc: "fitz.Document", toc_pages: set[int]) -> set[int]:
389
+ def dense_heading_pages(doc: "pymupdf.Document", toc_pages: set[int]) -> set[int]:
333
390
  """Pages listing many numbered headings: a contents page, whatever its
334
391
  typography. Detecting it by leader dots alone misses the ones set without
335
392
  them, and every heading read there points at the contents page instead of
@@ -407,7 +464,7 @@ def prune_chapters(chapters: dict[str, dict]) -> dict[str, dict]:
407
464
 
408
465
 
409
466
  def extract_chapters(
410
- doc: "fitz.Document", toc_pages: set[int] | None = None
467
+ doc: "pymupdf.Document", toc_pages: set[int] | None = None
411
468
  ) -> dict[str, dict]:
412
469
  """Top-level chapters: prefer PDF bookmarks; fallback to body-text regex
413
470
  (uppercase heading on its own line) when the PDF has no bookmarks."""
@@ -492,7 +549,7 @@ LABEL_RE = re.compile(
492
549
  )
493
550
 
494
551
 
495
- def infer_labelled_chapters(doc: "fitz.Document", skip: set[int]) -> dict[str, dict]:
552
+ def infer_labelled_chapters(doc: "pymupdf.Document", skip: set[int]) -> dict[str, dict]:
496
553
  """Chapters from labelled headings: "Art. 7 Titre" (a règlement, SIA 144)
497
554
  or "Section 7 Titre" (a Eurocode adopted as SIA 267.001).
498
555
 
@@ -558,7 +615,7 @@ SCOPE_TITLE_RE = re.compile(
558
615
  )
559
616
 
560
617
 
561
- def _page_top_titles(doc: "fitz.Document", pages: range, skip: set[int]) -> list[tuple[int, str]]:
618
+ def _page_top_titles(doc: "pymupdf.Document", pages: range, skip: set[int]) -> list[tuple[int, str]]:
562
619
  """Uppercase, plausible titles among the first lines of each page: where a
563
620
  chapter of a SIA norm starts. Deeper in a page a capitalised line is a
564
621
  table header or a note."""
@@ -574,7 +631,7 @@ def _page_top_titles(doc: "fitz.Document", pages: range, skip: set[int]) -> list
574
631
  return out
575
632
 
576
633
 
577
- def recover_missing_chapters(doc: "fitz.Document", chapters: dict[str, dict], skip: set[int]) -> dict[str, dict]:
634
+ def recover_missing_chapters(doc: "pymupdf.Document", chapters: dict[str, dict], skip: set[int]) -> dict[str, dict]:
578
635
  """Fill holes in the chapter sequence by position.
579
636
 
580
637
  On the OCR'd copies the numbering column is sometimes lost on a whole run
@@ -622,7 +679,7 @@ UNNUMBERED_UPPER_RE = re.compile(r"^([A-ZÉÈÀÂÔÎÛÇ][^a-z\n]{5,120})$")
622
679
  FIRST_SUB_RE = re.compile(r"^(\d{1,2})\.\d{1,2}\b")
623
680
 
624
681
 
625
- def infer_unnumbered_chapters(doc: "fitz.Document", skip: set[int]) -> dict[str, dict]:
682
+ def infer_unnumbered_chapters(doc: "pymupdf.Document", skip: set[int]) -> dict[str, dict]:
626
683
  """Chapters whose number the OCR lost.
627
684
 
628
685
  On a scanned copy the large chapter number is often the one glyph the OCR
@@ -664,7 +721,7 @@ def chapter_page_spans(chapters: dict[str, dict], n_pages: int) -> dict[str, tup
664
721
 
665
722
 
666
723
  def extract_subsections(
667
- doc: "fitz.Document",
724
+ doc: "pymupdf.Document",
668
725
  chap_prefixes: set[str],
669
726
  toc_pages: set[int],
670
727
  running: set[str],
@@ -870,7 +927,7 @@ def locate_heading(lines: list[str], node: dict) -> int:
870
927
 
871
928
 
872
929
  def extract_section_text(
873
- doc: "fitz.Document", nodes: list[dict], running: set[str]
930
+ doc: "pymupdf.Document", nodes: list[dict], running: set[str]
874
931
  ) -> list[str]:
875
932
  """Give each node the text between its heading and the next one.
876
933
 
@@ -998,7 +1055,7 @@ def prune_subsections(
998
1055
 
999
1056
 
1000
1057
  def extract_figures(
1001
- doc: "fitz.Document",
1058
+ doc: "pymupdf.Document",
1002
1059
  toc_pages: set[int],
1003
1060
  out_dir: Path,
1004
1061
  dpi: int = 150,
@@ -1007,7 +1064,7 @@ def extract_figures(
1007
1064
  figs_dir = out_dir / "figures"
1008
1065
  figs_dir.mkdir(parents=True, exist_ok=True)
1009
1066
  seen: dict[str, dict] = {}
1010
- matrix = fitz.Matrix(dpi / 72, dpi / 72)
1067
+ matrix = pymupdf.Matrix(dpi / 72, dpi / 72)
1011
1068
  for pageno in range(doc.page_count):
1012
1069
  if pageno in toc_pages:
1013
1070
  continue
@@ -1037,7 +1094,7 @@ def extract_figures(
1037
1094
  return list(seen.values())
1038
1095
 
1039
1096
 
1040
- def guess_language(doc: "fitz.Document") -> str:
1097
+ def guess_language(doc: "pymupdf.Document") -> str:
1041
1098
  """Best-effort language guess based on common French/German/Italian markers."""
1042
1099
  sample = " ".join(page_text(doc, i) for i in range(min(5, doc.page_count))).lower()
1043
1100
  scores = {
@@ -1059,6 +1116,11 @@ def main() -> None:
1059
1116
  help="Override detected language.",
1060
1117
  )
1061
1118
  ap.add_argument("--dpi", type=int, default=150, help="Figure render DPI.")
1119
+ ap.add_argument(
1120
+ "--licence-only",
1121
+ action="store_true",
1122
+ help="Print the licence marks of the PDF as JSON and stop (backfill of an ingested norm).",
1123
+ )
1062
1124
  args = ap.parse_args()
1063
1125
 
1064
1126
  pdf_path = Path(args.pdf).resolve()
@@ -1066,11 +1128,15 @@ def main() -> None:
1066
1128
  if not pdf_path.exists():
1067
1129
  sys.stderr.write(f"PDF not found: {pdf_path}\n")
1068
1130
  sys.exit(1)
1069
- out_dir.mkdir(parents=True, exist_ok=True)
1070
1131
 
1071
- doc = fitz.open(str(pdf_path))
1072
- toc_pages = detect_toc_pages(doc)
1132
+ doc = pymupdf.open(str(pdf_path))
1073
1133
  running = detect_running_text(doc)
1134
+ licence = detect_licence(doc, running)
1135
+ if args.licence_only:
1136
+ sys.stdout.write(json.dumps({"ok": True, "licence": licence}, ensure_ascii=False) + "\n")
1137
+ return
1138
+ out_dir.mkdir(parents=True, exist_ok=True)
1139
+ toc_pages = detect_toc_pages(doc)
1074
1140
  chapters = extract_chapters(doc, toc_pages)
1075
1141
  chap_prefixes = {k for k in chapters if k.isdigit()}
1076
1142
  spans = chapter_page_spans(chapters, doc.page_count)
@@ -1115,6 +1181,9 @@ def main() -> None:
1115
1181
  "metadata": {k: v for k, v in doc.metadata.items() if v},
1116
1182
  "tocPagesDetected": sorted(toc_pages),
1117
1183
  "runningHeadersDetected": sorted(running)[:10],
1184
+ # Watermark and copyright, kept once: `ingest_create_document`
1185
+ # stores them on the norm, and every excerpt carries them.
1186
+ "licence": licence,
1118
1187
  },
1119
1188
  "stats": {
1120
1189
  "sectionCount": len(nodes),
@@ -15,7 +15,10 @@ import importlib.util
15
15
  import sys
16
16
  from pathlib import Path
17
17
 
18
- import fitz
18
+ try:
19
+ import pymupdf
20
+ except ImportError: # PyMuPDF < 1.24.3 only knows the legacy name
21
+ import fitz as pymupdf
19
22
  import pytest
20
23
 
21
24
  HERE = Path(__file__).resolve().parent
@@ -29,7 +32,7 @@ spec.loader.exec_module(prepass) # type: ignore[union-attr]
29
32
 
30
33
  def write_pdf(path: Path, pages: list[list[str]], toc: list[list] | None = None) -> None:
31
34
  """One text line per list entry, 16 pt apart, top to bottom."""
32
- doc = fitz.open()
35
+ doc = pymupdf.open()
33
36
  for lines in pages:
34
37
  page = doc.new_page(width=595, height=842)
35
38
  y = 60
@@ -44,7 +47,7 @@ def write_pdf(path: Path, pages: list[list[str]], toc: list[list] | None = None)
44
47
 
45
48
  def run(pdf: Path, out: Path) -> dict:
46
49
  """The pre-pass as `main()` runs it, without the CLI."""
47
- doc = fitz.open(str(pdf))
50
+ doc = pymupdf.open(str(pdf))
48
51
  toc_pages = prepass.detect_toc_pages(doc)
49
52
  running = prepass.detect_running_text(doc)
50
53
  chapters = prepass.extract_chapters(doc, toc_pages)
@@ -209,3 +212,24 @@ def test_recovers_chapter_numbers_by_position(tmp_path):
209
212
  assert chapters["0"]["pageStart"] == 1
210
213
  assert prepass.missing_chapter_numbers(chapters) == []
211
214
 
215
+
216
+
217
+ WATERMARK = "Ecole Polytechnique Federale de Lausanne, EPFL, SNV / licensed to"
218
+
219
+
220
+ def test_licence_marks_are_kept_once(tmp_path):
221
+ """The footer and the vertical watermark leave the text, never the norm."""
222
+ pdf = sia_like(tmp_path)
223
+ doc = pymupdf.open(str(pdf))
224
+ for page in doc:
225
+ page.insert_text((20, 700), WATERMARK, fontsize=7, fontname="helv", rotate=90)
226
+ marked = tmp_path / "marked.pdf"
227
+ doc.save(str(marked))
228
+ doc.close()
229
+
230
+ doc = pymupdf.open(str(marked))
231
+ licence = prepass.detect_licence(doc, prepass.detect_running_text(doc))
232
+ lines = licence.splitlines()
233
+ assert HEADER in lines
234
+ assert WATERMARK in lines
235
+ assert len(lines) == len(set(lines))
@@ -7,7 +7,7 @@ description: Use Stratta to answer civil engineering questions from licensed SIA
7
7
 
8
8
  Use this skill when the user asks a technical civil engineering question that should be answered from the norms available in Stratta, or when the user asks to resume, create or update a Stratta project dossier.
9
9
 
10
- You answer practising engineers from the norms their organization holds in Stratta, and only from them. The corpus below is the whole of what you can read: a norm absent from it cannot be consulted, and a question outside it is answered by saying so, not by general knowledge. Reply in the language the user writes in; quote the norm in the language it is written in and translate only when asked.
10
+ You answer professionals (engineers, lawyers, accountants, public administrations and others) from the norms and reference documents their organization holds in Stratta (technical standards, laws, ordinances, regulations, accounting standards, internal references), and only from them. The corpus below is the whole of what you can read: a document absent from it cannot be consulted, and a question outside it is answered by saying so, not by general knowledge. "Norm" below means any such document. Reply in the language the user writes in; quote the norm in the language it is written in and translate only when asked.
11
11
 
12
12
  ## First call
13
13
 
@@ -19,7 +19,7 @@ Call `get_methodology` before the first technical answer of a conversation: it r
19
19
  2. `get_toc(code, maxDepth: 1)` on the norm that governs, then `get_subtree(code, chapter, maxDepth: 2)` on the chapter that fits: read summaries, choose the section.
20
20
  3. `get_section(code, path)` on the section retained. It comes as a document with its formulas and tables in place, its page range, the sections below it and the references it makes. Descend by `children` rather than loading a chapter whole; when `truncated` is true, call again with `offset`.
21
21
  4. Follow the references marked resolvable: the rule, the coefficient and the action are often spread over separate clauses or norms, and the answer is in the one you have not read yet.
22
- 5. `get_figure` when a section points at a figure the reasoning depends on: a load diagram, a design chart, a standard cross-section.
22
+ 5. `get_figure` when a section points at a figure the reasoning depends on: a diagram, a chart, a standard cross-section.
23
23
  6. `search_in_norm` inside one norm when its table of contents suggests no obvious section. Search in the language of the norm; accents and case do not matter; a clause number as keyword returns that clause.
24
24
 
25
25
  When the question names a norm absent from the corpus but a norm you hold refers to it, read that clause first: the pointer (code and clause number) usually lives there, and it is an answer.
@@ -27,10 +27,11 @@ When the question names a norm absent from the corpus but a norm you hold refers
27
27
  ## Answer contract
28
28
 
29
29
  - Every technical statement rests on a section you read in this conversation and cites it as `[CODE § path, p. N]`, for example `[SIA 267 § 9.5.2.1, p. 63]`. A value with no clause is not an answer.
30
- - A calculation shows its inputs, each with its citation, its formula in LaTeX (`$$...$$`) with every symbol defined and its unit (kN, kPa, MPa, m).
30
+ - A calculation shows its inputs, each with its citation, its formula in LaTeX (`$$...$$`) with every symbol defined and its unit (kN, MPa, CHF, %).
31
31
  - Before answering, check: each value has its clause; each clause was read, not remembered from a summary; the resolvable references were followed; what the corpus does not cover is named, with the norm or clause where it is likely to be found.
32
32
  - When several norms are cited, group by norm under headings: principle, then method, then conditions of use.
33
- - Once per conversation, say that the answer is indicative and that the full text of the norm must be consulted before use on a project.
33
+ - A norm that comes with a `licence` (watermark, copyright) carries it with every quote: print it once under the passages quoted from that norm, verbatim.
34
+ - Once per conversation, say that the answer is indicative and that the full text of the document must be consulted before it is relied on.
34
35
 
35
36
  ## Budget
36
37
 
@@ -38,27 +39,27 @@ No `get_toc` without `maxDepth`. No section loaded whole when its children would
38
39
 
39
40
  ## Demonstration document
40
41
 
41
- DEMO 001 is a fictional document shipped with Stratta so that navigation and citation can be exercised before a licensed norm is ingested. Answer a question about it exactly as you would for a norm: read the section, cite it as [DEMO 001 §path, p. N], give the value. Then say once, plainly, that the document is fictional and that none of its values may be used in a project: the office must ingest the norm it holds a licence for. Never refuse to read it, and never present its values as those of a real norm.
42
+ DEMO 001 is a fictional document shipped with Stratta so that navigation and citation can be exercised before the organization has deposited its own documents. Answer a question about it exactly as you would for a norm: read the section, cite it as [DEMO 001 §path, p. N], give the value. Then say once, plainly, that the document is fictional and that none of its values may be relied on: the organization must deposit the documents it holds the rights to. Never refuse to read it, and never present its values as those of a real norm.
42
43
 
43
44
  ## Dossier
44
45
 
45
46
  A conversation disappears, a project lasts months. The dossier is what remains: what was read, what was retained, and what is still open. Keeping it costs the user nothing: every section you read is filed under the project by itself, and one call records a decision.
46
47
 
47
- **Name the project once.** When the user names a project, a structure, a site or a mandate, call `list_dossiers` then `load_dossier` (or `open_dossier` for a new one) BEFORE answering. It reloads what was already decided so it is not decided twice, and from then on each `get_section` you make is journaled under that dossier without any further call. Do not open a dossier for a one-off lookup with no project behind it.
48
+ **Name the project once.** When the user names a project, a case, a structure, a site or a mandate, call `list_dossiers` then `load_dossier` (or `open_dossier` for a new one) BEFORE answering. It reloads what was already decided so it is not decided twice, and from then on each `get_section` you make is journaled under that dossier without any further call. Do not open a dossier for a one-off lookup with no project behind it.
48
49
 
49
- **Record what is retained** (`record_decision`) as soon as the user fixes a value or a choice for the design ("we take 30 degrees", "keep pile P38"): the decision in one line, why, the clause it rests on (`normCode`, `sectionPath`, `page`, `normEdition` from the header of the section read), the number in `value` with its unit and `confidence` (established, judgement or to_confirm; in geotechnics a retained value is a judgement). Pass `dossierId` and `question` (what is settled, in one line): the question is opened already decided. Pass `questionId` instead when the question exists. ONLY after the user confirmed it, never on your own reasoning.
50
+ **Record what is retained** (`record_decision`) as soon as the user fixes a value or a choice for the design ("we take 30 degrees", "keep option B", "the limitation period is 10 years"): the decision in one line, why, the clause it rests on (`normCode`, `sectionPath`, `page`, `normEdition` from the header of the section read), the number in `value` with its unit and `confidence` (established, judgement or to_confirm; a retained value that rests on professional judgement is a judgement). Pass `dossierId` and `question` (what is settled, in one line): the question is opened already decided. Pass `questionId` instead when the question exists. ONLY after the user confirmed it, never on your own reasoning.
50
51
 
51
52
  **Open a question** (`open_question`) only when something is left to settle later, or when the user weighs several options and wants them on the table. File what backs it with `save_finding` (one clause, one value or one fact per call, under its `questionId`): reference, hypothesis or observation. `get_question` reads one in full; `update_question` edits it; never delete one unless the user says so.
52
53
 
53
- **Read its attachments.** A dossier carries the project's own documents (site reports, borehole logs, meeting minutes): `list_attachments`, then `read_attachment` on what bears on the question, or `search_in_dossier` when the dossier holds many. A fact from a piece is evidence like a clause: cite it as [name, p. N] and file it with `attachmentId` and `attachmentPage`.
54
+ **Read its attachments.** A dossier carries the project's own documents (reports, logs, meeting minutes, contracts): `list_attachments`, then `read_attachment` on what bears on the question, or `search_in_dossier` when the dossier holds many. A fact from a piece is evidence like a clause: cite it as [name, p. N] and file it with `attachmentId` and `attachmentPage`.
54
55
 
55
- **Start from a template when one fits** (`list_templates`, `apply_template`): the office's checklist for a type of structure opens the questions with the clauses they rest on, and says which clauses the corpus lacks.
56
+ **Start from a template when one fits** (`list_templates`, `apply_template`): the organization's checklist for a type of case opens the questions with the clauses they rest on, and says which clauses the corpus lacks.
56
57
 
57
- **What not to write:** your own prose, the intermediate steps, anything the user did not treat as a decision. The dossier documents the engineer's reasoning; it does not replace it: never conclude that a structure complies, never write a decision the user has not confirmed.
58
+ **What not to write:** your own prose, the intermediate steps, anything the user did not treat as a decision. The dossier documents the professional's reasoning; it does not replace it: never conclude that a design or a situation complies, never write a decision the user has not confirmed.
58
59
 
59
60
  ## Skills
60
61
 
61
- Before drafting a document (project basis, use agreement, terrain chapter, verification note) or starting a repeatable task (instructing a type of structure, preparing a review), call `list_skills` then `get_skill` on the one that fits, and follow it. The organization's own skill replaces Stratta's of the same name: it carries the office's plan and wording. When no skill fits, say so and proceed with the method above.
62
+ Before drafting a document (project basis, use agreement, terrain chapter, verification note, opinion) or starting a repeatable task (instructing a type of case, preparing a review), call `list_skills` then `get_skill` on the one that fits, and follow it. The organization's own skill replaces Stratta's of the same name: it carries the organization's plan and wording. When no skill fits, say so and proceed with the method above.
62
63
 
63
64
  ## Boundaries
64
65
 
@@ -86,7 +86,10 @@ Output under `.stratta-ingest/<code-slug>/`:
86
86
 
87
87
  `prepass.json` structure:
88
88
 
89
- - `doc` — `pageCount`, detected `language`, `tocSource`, raw `metadata`.
89
+ - `doc` — `pageCount`, detected `language`, `tocSource`, raw `metadata`,
90
+ and `licence`: the watermark and copyright lines of the copy, kept once.
91
+ The page text leaves them out; the norm keeps them and every excerpt
92
+ served from it shows them.
90
93
  - `stats` — `sectionCount`, `byDepth`, `figureCount`, `charsPerPage`,
91
94
  `emptyLeaves`. `charsPerPage` under 300 means the text layer is missing
92
95
  (a scan without OCR): stop and tell the user.
@@ -113,10 +116,12 @@ numbered clauses stay inside their heading's text.
113
116
  ### 4. Create the document
114
117
 
115
118
  Read `prepass.json`, then:
116
- `ingest_create_document { code, year, title, language: <doc.language>, totalPages: <doc.pageCount>, scope }`
119
+ `ingest_create_document { code, year, title, language: <doc.language>, totalPages: <doc.pageCount>, scope, licence: <doc.licence> }`
117
120
  where `scope` is one sentence on what the norm covers (from its first chapter);
118
121
  it is shown to every agent in the served methodology, so write it as a routing
119
122
  hint: "Geotechnical design: foundations, piles, anchors, retaining structures".
123
+ Pass `licence` exactly as the pre-pass wrote it, never edited or shortened;
124
+ omit it only when the pre-pass found none.
120
125
  → returns `documentId`. Keep it for every subsequent call.
121
126
 
122
127
  ### 5. Validate the tree, write the summaries