@stratta/mcp 1.17.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 +18 -8
- package/dist/tools/catalog.gen.d.ts +1 -1
- package/dist/tools/catalog.gen.js +71 -23
- package/dist/tools/ingest.d.ts +2 -0
- package/dist/tools/ingest.js +5 -0
- package/dist/tools/toolsets.gen.js +4 -0
- package/package.json +2 -2
- package/scripts/ingest-prepass.py +91 -22
- package/scripts/tests/test_prepass.py +27 -3
- package/skills/consult-stratta/SKILL.md +12 -11
- package/skills/ingest-norm/SKILL.md +7 -2
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
|
|
13
|
-
your
|
|
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.
|
|
@@ -34,7 +35,7 @@ https://stratta.ch/mcp
|
|
|
34
35
|
That is the shorter path, the one the dashboard walks you through for Claude,
|
|
35
36
|
ChatGPT, Claude Code, Codex, Cursor, VS Code, Gemini CLI and Windsurf, and the
|
|
36
37
|
only one that works in an agent running in the cloud (claude.ai, ChatGPT). It
|
|
37
|
-
serves
|
|
38
|
+
serves 61 of the 62 tools below, **ingestion included**: the pre-pass script is
|
|
38
39
|
downloaded from https://stratta.ch/ingest-prepass.py when it is not on disk.
|
|
39
40
|
See https://stratta.ch/docs/en/guides/connect-remote.
|
|
40
41
|
|
|
@@ -191,8 +192,9 @@ named `E2E dossier <timestamp>` and does not delete it.
|
|
|
191
192
|
|
|
192
193
|
## Tools exposed
|
|
193
194
|
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
62 tools, generated from one catalogue shared with the remote connector. This
|
|
196
|
+
package announces 59 of them; the remote connector serves 61: everything minus
|
|
197
|
+
`add_attachment`, plus the three tools a host calls for the person, listed last.
|
|
196
198
|
|
|
197
199
|
**Read** (10 tools — query the norms of your workspace):
|
|
198
200
|
|
|
@@ -232,7 +234,7 @@ serves the same 58, everything minus `add_attachment`).
|
|
|
232
234
|
| `open_dossier` | Open a project's dossier, creating it if needed. Idempotent on the name. |
|
|
233
235
|
| `open_question` | Open one question to settle, with optional named options. Idempotent on the title. |
|
|
234
236
|
| `save_finding` | Record one piece of evidence: a cited article, a retained value and why, an observation. |
|
|
235
|
-
| `record_decision` | Settle a question with a decision the
|
|
237
|
+
| `record_decision` | Settle a question with a decision the user has confirmed, and the retained option. |
|
|
236
238
|
| `load_dossier` | Reload everything: questions with their evidence and decisions, open ones first. |
|
|
237
239
|
| `resolve_question` | Close a question without a decision, or reopen one. The evidence stays. |
|
|
238
240
|
| `list_attachments` | The project attachments of a dossier: site reports, borehole logs, minutes, data sheets. |
|
|
@@ -276,7 +278,7 @@ of the organisation. A report template learned from an office Word file on
|
|
|
276
278
|
[stratta.ch/bibliotheque](https://stratta.ch/bibliotheque) is served as a skill
|
|
277
279
|
too, and its file is the layout the Word export is rendered into.
|
|
278
280
|
|
|
279
|
-
**Ingest** (10 tools — add YOUR
|
|
281
|
+
**Ingest** (10 tools — add YOUR documents; driven by the bundled `ingest-norm` skill; owner or admin role):
|
|
280
282
|
|
|
281
283
|
| Tool | Purpose |
|
|
282
284
|
| ----------------------------- | -------------------------------------------------------------------------------- |
|
|
@@ -291,6 +293,14 @@ too, and its file is the layout the Word export is rendered into.
|
|
|
291
293
|
| `ingest_publish` | Score the document and flip it to published; refuses below 30/100 unless forced. |
|
|
292
294
|
| `ingest_delete` | Delete a document and all its children. |
|
|
293
295
|
|
|
296
|
+
**Remote only** (3 tools, served by `https://stratta.ch/mcp` and not by this package, which draws no interface):
|
|
297
|
+
|
|
298
|
+
| Tool | Purpose |
|
|
299
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
|
300
|
+
| `show_citations` | Show the clauses an answer cites as cards (an MCP App), each quote checked word for word against the clause. |
|
|
301
|
+
| `open_norm_reader` | The norm reader, opened by the person from the host's side panel or sidebar; hidden from the model. |
|
|
302
|
+
| `get_profile` | An opaque id and the organization of the connection, so a host tells two connected accounts apart. |
|
|
303
|
+
|
|
294
304
|
## How agents should use it
|
|
295
305
|
|
|
296
306
|
For querying:
|
|
@@ -327,7 +337,7 @@ Three prompts appear as slash commands in clients that support them:
|
|
|
327
337
|
`investigate-question` (question, project?), `resume-dossier` (name) and
|
|
328
338
|
`check-value` (value, norm?).
|
|
329
339
|
|
|
330
|
-
For
|
|
340
|
+
For depositing your own documents, see the bundled **`ingest-norm` skill** —
|
|
331
341
|
a 2-phase hybrid pipeline (since 0.4.0): a Python pre-pass
|
|
332
342
|
(`scripts/ingest-prepass.py`, PyMuPDF) extracts the hierarchical tree and
|
|
333
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
|
|
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
|
|
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\", \"
|
|
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\", \"
|
|
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\", \"
|
|
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
|
|
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\", \"
|
|
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.
|
|
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\", \"
|
|
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:
|
|
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,11 +189,58 @@ 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\", \"
|
|
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
|
}
|
|
196
196
|
},
|
|
197
|
+
{
|
|
198
|
+
"name": "show_citations",
|
|
199
|
+
"title": "Show cited clauses",
|
|
200
|
+
"description": "Show the person the clauses your answer rests on, as cards they can open and check against the norm. Call it once, after answering, with the sections you actually read with get_section and cited, in the order you cited them. It reads nothing new: it never replaces get_section, and a clause you did not read must not be listed.",
|
|
201
|
+
"annotations": {
|
|
202
|
+
"readOnlyHint": true,
|
|
203
|
+
"destructiveHint": false,
|
|
204
|
+
"idempotentHint": true,
|
|
205
|
+
"openWorldHint": false
|
|
206
|
+
},
|
|
207
|
+
"transports": [
|
|
208
|
+
"remote"
|
|
209
|
+
],
|
|
210
|
+
"params": {
|
|
211
|
+
"citations": "The cited clauses, one to eight: code and path as get_section took them, the edition when the norm has several, and optionally the exact sentence quoted from the clause."
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "open_norm_reader",
|
|
216
|
+
"title": "Norm reader",
|
|
217
|
+
"description": "Open the norm reader: the norms of the signed-in organization, each with its table of contents and its clauses, browsed by the person. Opened by the person from the side panel or the sidebar, never by the model.",
|
|
218
|
+
"annotations": {
|
|
219
|
+
"readOnlyHint": true,
|
|
220
|
+
"destructiveHint": false,
|
|
221
|
+
"idempotentHint": true,
|
|
222
|
+
"openWorldHint": false
|
|
223
|
+
},
|
|
224
|
+
"transports": [
|
|
225
|
+
"remote"
|
|
226
|
+
],
|
|
227
|
+
"params": {}
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"name": "get_profile",
|
|
231
|
+
"title": "Connected profile",
|
|
232
|
+
"description": "Return the profile behind this connection: a stable opaque id and the organization the connection reads from. Used by the host to tell several connected accounts apart; to check the workspace, call whoami.",
|
|
233
|
+
"annotations": {
|
|
234
|
+
"readOnlyHint": true,
|
|
235
|
+
"destructiveHint": false,
|
|
236
|
+
"idempotentHint": true,
|
|
237
|
+
"openWorldHint": false
|
|
238
|
+
},
|
|
239
|
+
"transports": [
|
|
240
|
+
"remote"
|
|
241
|
+
],
|
|
242
|
+
"params": {}
|
|
243
|
+
},
|
|
197
244
|
{
|
|
198
245
|
"name": "scan_site",
|
|
199
246
|
"title": "Scan a site",
|
|
@@ -441,7 +488,7 @@ export const CATALOG = [
|
|
|
441
488
|
{
|
|
442
489
|
"name": "save_finding",
|
|
443
490
|
"title": "Record evidence",
|
|
444
|
-
"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
|
|
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.",
|
|
445
492
|
"annotations": {
|
|
446
493
|
"readOnlyHint": false,
|
|
447
494
|
"destructiveHint": false,
|
|
@@ -456,10 +503,10 @@ export const CATALOG = [
|
|
|
456
503
|
"dossierId": "Dossier id returned by open_dossier or list_dossiers.",
|
|
457
504
|
"questionId": "The question this evidence serves, from open_question or load_dossier. Omit only when you do not know yet.",
|
|
458
505
|
"kind": "reference, hypothesis or observation: pick by what the entry is.",
|
|
459
|
-
"title": "The fact in one line, as
|
|
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.",
|
|
460
507
|
"detail": "The reasoning in one or two sentences: why this value, what it rests on, what disagreed. Expected for a hypothesis.",
|
|
461
508
|
"value": "The retained value with its unit, e.g. \"30 deg\", \"1.25 kN/m2\".",
|
|
462
|
-
"confidence": "established (computed or read directly), judgement (the
|
|
509
|
+
"confidence": "established (computed or read directly), judgement (the user chose it), to_confirm (provisional, pending a test).",
|
|
463
510
|
"normCode": "Norm this comes from, e.g. \"SIA 267\".",
|
|
464
511
|
"sectionPath": "Exact section path, e.g. \"9.5.2.1\".",
|
|
465
512
|
"page": "Page in the norm, so a human can verify it in their PDF.",
|
|
@@ -471,7 +518,7 @@ export const CATALOG = [
|
|
|
471
518
|
{
|
|
472
519
|
"name": "record_decision",
|
|
473
520
|
"title": "Record a decision",
|
|
474
|
-
"description": "Record what the
|
|
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.",
|
|
475
522
|
"annotations": {
|
|
476
523
|
"readOnlyHint": false,
|
|
477
524
|
"destructiveHint": false,
|
|
@@ -540,7 +587,7 @@ export const CATALOG = [
|
|
|
540
587
|
{
|
|
541
588
|
"name": "list_attachments",
|
|
542
589
|
"title": "Project attachments",
|
|
543
|
-
"description": "List the project attachments of one dossier: the site reports, borehole logs, meeting minutes and data sheets the
|
|
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.",
|
|
544
591
|
"annotations": {
|
|
545
592
|
"readOnlyHint": true,
|
|
546
593
|
"destructiveHint": false,
|
|
@@ -617,7 +664,7 @@ export const CATALOG = [
|
|
|
617
664
|
{
|
|
618
665
|
"name": "list_templates",
|
|
619
666
|
"title": "Dossier templates",
|
|
620
|
-
"description": "List the checklists the organization wrote for its types of
|
|
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.",
|
|
621
668
|
"annotations": {
|
|
622
669
|
"readOnlyHint": true,
|
|
623
670
|
"destructiveHint": false,
|
|
@@ -942,7 +989,7 @@ export const CATALOG = [
|
|
|
942
989
|
],
|
|
943
990
|
"params": {
|
|
944
991
|
"entryId": "Entry id from get_question or load_dossier.",
|
|
945
|
-
"title": "The fact in one line, as
|
|
992
|
+
"title": "The fact in one line, as a professional would write it in a report.",
|
|
946
993
|
"detail": "The reasoning in one or two sentences.",
|
|
947
994
|
"value": "The retained value with its unit, e.g. \"30 deg\".",
|
|
948
995
|
"confidence": "established, judgement or to_confirm.",
|
|
@@ -993,7 +1040,7 @@ export const CATALOG = [
|
|
|
993
1040
|
{
|
|
994
1041
|
"name": "ingest_create_document",
|
|
995
1042
|
"title": "Create norm document",
|
|
996
|
-
"description": "Create a draft norm document in the workspace. Returns the documentId every later ingest_* call needs. The organization must hold
|
|
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.",
|
|
997
1044
|
"annotations": {
|
|
998
1045
|
"readOnlyHint": false,
|
|
999
1046
|
"destructiveHint": false,
|
|
@@ -1011,7 +1058,8 @@ export const CATALOG = [
|
|
|
1011
1058
|
"language": "fr, de, it or en.",
|
|
1012
1059
|
"totalPages": "Page count of the source PDF.",
|
|
1013
1060
|
"edition": "Edition label as printed on the cover (\"2013\", \"2020+C1\"). Default: the year. Lets several editions of one code coexist.",
|
|
1014
|
-
"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."
|
|
1015
1063
|
}
|
|
1016
1064
|
},
|
|
1017
1065
|
{
|
|
@@ -1179,12 +1227,12 @@ export const CATALOG = [
|
|
|
1179
1227
|
export const PROMPTS = [
|
|
1180
1228
|
{
|
|
1181
1229
|
"name": "investigate-question",
|
|
1182
|
-
"title": "Investigate a question in the
|
|
1183
|
-
"description": "Answer a
|
|
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.",
|
|
1184
1232
|
"args": [
|
|
1185
1233
|
{
|
|
1186
1234
|
"name": "question",
|
|
1187
|
-
"description": "The
|
|
1235
|
+
"description": "The question, as the user would ask a colleague.",
|
|
1188
1236
|
"required": true
|
|
1189
1237
|
},
|
|
1190
1238
|
{
|
package/dist/tools/ingest.d.ts
CHANGED
|
@@ -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<{
|
package/dist/tools/ingest.js
CHANGED
|
@@ -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, {
|
|
@@ -23,6 +23,10 @@ export const TOOLSET_OF = {
|
|
|
23
23
|
search_corpus: 'norms',
|
|
24
24
|
get_cross_refs: 'norms',
|
|
25
25
|
get_figure: 'norms',
|
|
26
|
+
// Shown to the person rather than read by the model (ADR 59): remote only.
|
|
27
|
+
show_citations: 'norms',
|
|
28
|
+
open_norm_reader: 'norms',
|
|
29
|
+
get_profile: 'norms',
|
|
26
30
|
list_dossiers: 'dossiers',
|
|
27
31
|
open_dossier: 'dossiers',
|
|
28
32
|
load_dossier: 'dossiers',
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratta/mcp",
|
|
3
3
|
"mcpName": "ch.stratta/mcp",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "MCP server exposing
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 =
|
|
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: "
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|