@stratta/mcp 1.18.0 → 1.19.0
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 +6 -5
- package/dist/tools/catalog.gen.d.ts +1 -1
- package/dist/tools/catalog.gen.js +31 -30
- package/dist/tools/ingest.d.ts +2 -0
- package/dist/tools/ingest.js +5 -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 +13 -12
- 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.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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. A question these documents are meant to settle is answered from them, not from memory; 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,11 +1,11 @@
|
|
|
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. A question these documents are meant to settle is answered from them, not from memory; a norm absent from `list_norms` cannot be read.";
|
|
4
4
|
export const CATALOG = [
|
|
5
5
|
{
|
|
6
6
|
"name": "get_methodology",
|
|
7
7
|
"title": "Working method",
|
|
8
|
-
"description": "Read the working contract this server expects an agent to follow: how to walk a norm from its table of contents down to a clause, how to cite what it reads, and when to record something in a project dossier.
|
|
8
|
+
"description": "Read the working contract this server expects an agent to follow: how to walk a norm from its table of contents down to a clause, how to cite what it reads, and when to record something in a project dossier. Use this at the start of a conversation that will rely on the documents of the organization; its rules hold for the rest of that conversation.",
|
|
9
9
|
"annotations": {
|
|
10
10
|
"readOnlyHint": true,
|
|
11
11
|
"destructiveHint": false,
|
|
@@ -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).
|
|
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). Use this when a question may be settled by the documents of the organization and the norm is not yet known: a norm absent from this list cannot be read here. 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."
|
|
@@ -138,7 +138,7 @@ export const CATALOG = [
|
|
|
138
138
|
{
|
|
139
139
|
"name": "search_corpus",
|
|
140
140
|
"title": "Search all norms",
|
|
141
|
-
"description": "Full-text search across every norm the organization can read, grouped by norm, heading hits from any norm ahead of body hits. Use
|
|
141
|
+
"description": "Full-text search across every norm the organization can read, grouped by norm, heading hits from any norm ahead of body hits. Use this when the question does not name its norm: it tells which norms speak of the subject before any table of contents is opened. Same matching as search_in_norm. Returns, per norm, its code and title and a few hits; continue with get_section on the paths that look relevant.",
|
|
142
142
|
"annotations": {
|
|
143
143
|
"readOnlyHint": true,
|
|
144
144
|
"destructiveHint": false,
|
|
@@ -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,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\", \"
|
|
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
|
}
|
|
@@ -197,7 +197,7 @@ export const CATALOG = [
|
|
|
197
197
|
{
|
|
198
198
|
"name": "show_citations",
|
|
199
199
|
"title": "Show cited clauses",
|
|
200
|
-
"description": "
|
|
200
|
+
"description": "Use this when an answer rests on clauses read with get_section and the person should be able to check them: shows those clauses as cards they can open against the norm, once per answer, in the order they were cited. It reads nothing new and does not replace get_section; only clauses actually read belong in it.",
|
|
201
201
|
"annotations": {
|
|
202
202
|
"readOnlyHint": true,
|
|
203
203
|
"destructiveHint": false,
|
|
@@ -488,7 +488,7 @@ export const CATALOG = [
|
|
|
488
488
|
{
|
|
489
489
|
"name": "save_finding",
|
|
490
490
|
"title": "Record evidence",
|
|
491
|
-
"description": "
|
|
491
|
+
"description": "Use this when a piece of evidence turns up during the work: records it in a dossier, one at a time 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
|
|
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
|
|
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": "
|
|
521
|
+
"description": "Use this when the user has settled a point: records 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. Meant for decisions the user confirmed, not for conclusions of the agent alone. Returns entryId.",
|
|
522
522
|
"annotations": {
|
|
523
523
|
"readOnlyHint": false,
|
|
524
524
|
"destructiveHint": false,
|
|
@@ -548,7 +548,7 @@ export const CATALOG = [
|
|
|
548
548
|
{
|
|
549
549
|
"name": "load_dossier",
|
|
550
550
|
"title": "Load a dossier",
|
|
551
|
-
"description": "Reload a dossier: its questions with their evidence, options and decisions, open questions first, then the evidence not yet filed under a question, then the comments a colleague left.
|
|
551
|
+
"description": "Reload a dossier: its questions with their evidence, options and decisions, open questions first, then the evidence not yet filed under a question, then the comments a colleague left. Use this when the user returns to a project already named: the decisions already taken are there. The name is enough; the id is optional.",
|
|
552
552
|
"annotations": {
|
|
553
553
|
"readOnlyHint": true,
|
|
554
554
|
"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
|
|
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. Use this when a question on the project may rest on what the office uploaded; read_attachment opens one, and a piece is cited like a clause.",
|
|
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
|
|
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. Use this when a new project may match a type of case the office already wrote down; apply_template opens all its questions at once.",
|
|
668
668
|
"annotations": {
|
|
669
669
|
"readOnlyHint": true,
|
|
670
670
|
"destructiveHint": false,
|
|
@@ -757,7 +757,7 @@ export const CATALOG = [
|
|
|
757
757
|
{
|
|
758
758
|
"name": "get_skill",
|
|
759
759
|
"title": "Read a skill",
|
|
760
|
-
"description": "Read one skill in full: Markdown with its front matter (name, description) and its steps, rules and output shape.
|
|
760
|
+
"description": "Read one skill in full: Markdown with its front matter (name, description) and its steps, rules and output shape. Use this when a task matches a skill from list_skills; the skill describes how the office does it. The organization version wins over the Stratta version of the same name, because it carries the office plan and wording. Returns null when no skill has that name.",
|
|
761
761
|
"annotations": {
|
|
762
762
|
"readOnlyHint": true,
|
|
763
763
|
"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
|
|
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.",
|
|
@@ -1022,7 +1022,7 @@ export const CATALOG = [
|
|
|
1022
1022
|
{
|
|
1023
1023
|
"name": "ingest_status",
|
|
1024
1024
|
"title": "Check ingestion status",
|
|
1025
|
-
"description": "Check whether a norm (by code) already exists in the workspace, how many sections it has and its coverage score (how much of the norm actually made it in).
|
|
1025
|
+
"description": "Check whether a norm (by code) already exists in the workspace, how many sections it has and its coverage score (how much of the norm actually made it in). Use this before creating a document, so a norm is not ingested twice.",
|
|
1026
1026
|
"annotations": {
|
|
1027
1027
|
"readOnlyHint": true,
|
|
1028
1028
|
"destructiveHint": false,
|
|
@@ -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
|
|
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
|
{
|
|
@@ -1169,7 +1170,7 @@ export const CATALOG = [
|
|
|
1169
1170
|
{
|
|
1170
1171
|
"name": "ingest_normalize_cross_refs",
|
|
1171
1172
|
"title": "Normalize cross-references",
|
|
1172
|
-
"description": "Scan every section of a document and
|
|
1173
|
+
"description": "Scan every section of a document and add the cross-references to other norms found in its text that are not recorded yet; existing ones are kept. Idempotent; run it after the sections are in.",
|
|
1173
1174
|
"annotations": {
|
|
1174
1175
|
"readOnlyHint": false,
|
|
1175
1176
|
"destructiveHint": false,
|
|
@@ -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
|
|
1230
|
-
"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.",
|
|
1231
1232
|
"args": [
|
|
1232
1233
|
{
|
|
1233
1234
|
"name": "question",
|
|
1234
|
-
"description": "The
|
|
1235
|
+
"description": "The question, as the user would ask a colleague.",
|
|
1235
1236
|
"required": true
|
|
1236
1237
|
},
|
|
1237
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, {
|
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.19.0",
|
|
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))
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: consult-stratta
|
|
3
|
-
description: Use Stratta to answer
|
|
3
|
+
description: Use Stratta to answer professional questions from the norms and reference documents the organization deposited (technical standards such as SIA or Eurocodes, laws, ordinances, regulations, accounting standards, in-house references) with exact citations, cross-reference checks, figures, and optional project dossiers. Trigger phrases: "selon la norme", "que dit la loi", "d'apres la SIA", "que dit l'Eurocode", "verifie dans les normes", "reprends le dossier".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Consult Stratta
|
|
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
|