metanorma-mko 1.0.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.
- checksums.yaml +7 -0
- data/LICENSE +25 -0
- data/README.adoc +44 -0
- data/lib/metanorma/mko/alignment.rb +53 -0
- data/lib/metanorma/mko/assets.rb +63 -0
- data/lib/metanorma/mko/bundle.rb +91 -0
- data/lib/metanorma/mko/diff.rb +97 -0
- data/lib/metanorma/mko/export.rb +27 -0
- data/lib/metanorma/mko/mcp.rb +162 -0
- data/lib/metanorma/mko/result.rb +29 -0
- data/lib/metanorma/mko/schema/collection.rb +44 -0
- data/lib/metanorma/mko/schema/document.rb +119 -0
- data/lib/metanorma/mko/schema/edge.rb +22 -0
- data/lib/metanorma/mko/schema/identifiers.rb +31 -0
- data/lib/metanorma/mko/schema/json_schema.rb +120 -0
- data/lib/metanorma/mko/schema/manifest.rb +59 -0
- data/lib/metanorma/mko/schema/unit.rb +221 -0
- data/lib/metanorma/mko/schema.rb +20 -0
- data/lib/metanorma/mko/units.rb +109 -0
- data/lib/metanorma/mko/version.rb +10 -0
- data/lib/metanorma/mko/writer.rb +62 -0
- data/lib/metanorma/mko.rb +53 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8d552e397dd8ed669ba8766736e4fd97621ef7b3203598b162ff989c84691a59
|
|
4
|
+
data.tar.gz: 0d1babd1f3cfcecf0aa61055a4798bb91edcd7847c9936a624cbbcfe9dec45a1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bbe6e4797d6290ca828ed59c124d3b452d2b9a10f7ca59533e18c23be2f42316da55edbd14d2dbd88a8c9636659b6c15b7bde605c2cf1205a0f8a0909f9d034c
|
|
7
|
+
data.tar.gz: 6fa2e380044eecad731b5258b7c424651a815a3a0b6522ae47699d97e037ae121a49e770d8982bf5af89645ae5f2d0559f11a28d6ee31e5ed21074903406af07
|
data/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Ribose Inc.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
17
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
18
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
19
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
20
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
21
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
22
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
23
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
24
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
25
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.adoc
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
= metanorma-mko
|
|
2
|
+
|
|
3
|
+
Metanorma Knowledge Objects (MKO, MN 116) — the machine serialization
|
|
4
|
+
format for Metanorma documents, as code.
|
|
5
|
+
|
|
6
|
+
== What this gem owns
|
|
7
|
+
|
|
8
|
+
The FORMAT contract:
|
|
9
|
+
|
|
10
|
+
* the MN 116 wire schema (typed units, edges, document identity,
|
|
11
|
+
manifest) as lutaml-model classes — `Metanorma::Mko::Schema`;
|
|
12
|
+
* the bundle layout and manifest verification — `Mko::Bundle`,
|
|
13
|
+
`Mko::Writer`;
|
|
14
|
+
* hash-addressed assets (`Mko::Assets`);
|
|
15
|
+
* structured edition diffs (`Mko::Diff`) and interlingual alignment
|
|
16
|
+
(`Mko::Alignment`);
|
|
17
|
+
* the generated JSON Schemas — `Mko.json_schemas` (the schema classes
|
|
18
|
+
are the single source of truth; TS/Python consumers validate against
|
|
19
|
+
these, never hand-rolled copies);
|
|
20
|
+
* the reference MCP server over any conforming bundle —
|
|
21
|
+
`Mko::Mcp::Server` (search_units / get_unit / walk_edges /
|
|
22
|
+
edition_diff; JSON-RPC 2.0 over stdio).
|
|
23
|
+
|
|
24
|
+
== What it does not own
|
|
25
|
+
|
|
26
|
+
The model side — the projection walk, collection orchestration, flavor
|
|
27
|
+
resolution — lives in
|
|
28
|
+
https://github.com/metanorma/metanorma-document[metanorma-document],
|
|
29
|
+
which depends on this gem and reopens `Metanorma::Mko` with the export
|
|
30
|
+
entry points (`Mko.export`, `Mko::Collection.export`).
|
|
31
|
+
|
|
32
|
+
== Usage
|
|
33
|
+
|
|
34
|
+
Consumers validate against the published contract:
|
|
35
|
+
|
|
36
|
+
[source,ruby]
|
|
37
|
+
require "metanorma/mko"
|
|
38
|
+
Metanorma::Mko.json_schemas["unit"]
|
|
39
|
+
# => { "$schema" => "https://json-schema.org/draft/2020-12/schema", ... }
|
|
40
|
+
|
|
41
|
+
Agent consumers mount the reference MCP server:
|
|
42
|
+
|
|
43
|
+
[source,ruby]
|
|
44
|
+
Metanorma::Mko::Mcp::Server.new("oiml-r-60-1.mko").run
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Mko
|
|
7
|
+
# Interlingual alignment (MN 116 §stability, P2): units across
|
|
8
|
+
# language editions of the same document are the same knowledge
|
|
9
|
+
# object when their anchors match — Metanorma translations carry
|
|
10
|
+
# the semantic anchors. Emits variant_of unit edges binding the
|
|
11
|
+
# editions: parallel corpora, cross-language retrieval with
|
|
12
|
+
# source-language provenance, and consistency diffs between
|
|
13
|
+
# translations.
|
|
14
|
+
module Alignment
|
|
15
|
+
class << self
|
|
16
|
+
# Align two bundles of the same document (different language
|
|
17
|
+
# editions) by stable unit anchor. Returns [Schema::Edge] with
|
|
18
|
+
# kind variant_of; content-hash anchors (h-…) and unmatched
|
|
19
|
+
# units are simply not aligned — never guessed.
|
|
20
|
+
def align(bundle_a, bundle_b)
|
|
21
|
+
a = units_by_anchor(bundle_a)
|
|
22
|
+
b = units_by_anchor(bundle_b)
|
|
23
|
+
a.keys.intersection(b.keys).filter_map do |anchor|
|
|
24
|
+
next if anchor.to_s.empty? || anchor.start_with?("h-")
|
|
25
|
+
|
|
26
|
+
Schema::Edge.new(from: a[anchor], to: b[anchor],
|
|
27
|
+
kind: "variant_of")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Write alignment.jsonl into bundle_a's directory; returns the
|
|
32
|
+
# path. The alignment is a derived artifact — regeneration
|
|
33
|
+
# replaces it.
|
|
34
|
+
def export(bundle_a, bundle_b)
|
|
35
|
+
edges = align(bundle_a, bundle_b)
|
|
36
|
+
path = File.join(bundle_a, "alignment.jsonl")
|
|
37
|
+
File.write(path, edges.map(&:to_json).join("\n") + "\n")
|
|
38
|
+
path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def units_by_anchor(bundle)
|
|
44
|
+
file = File.join(bundle, "units.jsonl")
|
|
45
|
+
File.readlines(file).each_with_object({}) do |line, h|
|
|
46
|
+
unit = JSON.parse(line)
|
|
47
|
+
h[unit["anchor"]] = unit["id"]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Metanorma
|
|
7
|
+
module Mko
|
|
8
|
+
# Hash-addressed figure assets (MN 116 §assets). Bytes come from
|
|
9
|
+
# the model when it carries them (data URIs) and from the caller's
|
|
10
|
+
# source directory for relative paths — never from the walk's
|
|
11
|
+
# environment. Every asset lands in the bundle as assets/<sha256>,
|
|
12
|
+
# manifest-verified like every other component.
|
|
13
|
+
class Assets
|
|
14
|
+
Entry = Struct.new(:name, :media_type, :data, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
DATA_URI = /\Adata:([^;,]+)(?:;charset=[^;,]+)?;base64,(.*)\z/m.freeze
|
|
17
|
+
MEDIA_TYPES = {
|
|
18
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
19
|
+
".gif" => "image/gif", ".svg" => "image/svg+xml",
|
|
20
|
+
".tif" => "image/tiff", ".tiff" => "image/tiff", ".webp" => "image/webp",
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(source_dir: nil)
|
|
24
|
+
@source_dir = source_dir
|
|
25
|
+
@entries = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the asset reference ("assets/<sha256>") for a figure
|
|
29
|
+
# source, or nil when no bytes are available.
|
|
30
|
+
def attach(uri)
|
|
31
|
+
data, media_type = bytes_for(uri)
|
|
32
|
+
return nil unless data
|
|
33
|
+
|
|
34
|
+
sha = Digest::SHA256.hexdigest(data)
|
|
35
|
+
name = "assets/#{sha}"
|
|
36
|
+
@entries[name] ||= Entry.new(
|
|
37
|
+
name: name, media_type: media_type || media_type_for(uri), data: data
|
|
38
|
+
)
|
|
39
|
+
name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def entries
|
|
43
|
+
@entries.values
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def bytes_for(uri)
|
|
49
|
+
text = uri.to_s
|
|
50
|
+
if (m = text.match(DATA_URI))
|
|
51
|
+
[Base64.decode64(m[2]), m[1]]
|
|
52
|
+
elsif @source_dir && !text.empty?
|
|
53
|
+
path = File.expand_path(text, @source_dir)
|
|
54
|
+
[File.binread(path), nil] if File.file?(path)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def media_type_for(uri)
|
|
59
|
+
MEDIA_TYPES[File.extname(uri.to_s).downcase] || "application/octet-stream"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Mko
|
|
5
|
+
# The on-disk bundle writer: one place that knows the file layout,
|
|
6
|
+
# the manifest bookkeeping, and the hash verification inputs. The
|
|
7
|
+
# projection Writer and the collection exporter both compose it —
|
|
8
|
+
# bundle mechanics are never duplicated.
|
|
9
|
+
class Bundle
|
|
10
|
+
attr_reader :dir
|
|
11
|
+
|
|
12
|
+
def self.open(short, to)
|
|
13
|
+
new(File.expand_path(File.join(to, "#{short}.mko")))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(dir)
|
|
17
|
+
@dir = dir
|
|
18
|
+
@manifest = Schema::Manifest.new(
|
|
19
|
+
schema: SCHEMA, schema_version: SCHEMA_VERSION,
|
|
20
|
+
generated: Schema::Generated.new(
|
|
21
|
+
tool: "metanorma-document", schema_version: SCHEMA_VERSION,
|
|
22
|
+
),
|
|
23
|
+
components: []
|
|
24
|
+
)
|
|
25
|
+
FileUtils.rm_rf(dir)
|
|
26
|
+
FileUtils.mkdir_p(dir)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_json(name, file, object)
|
|
30
|
+
path = File.join(dir, file)
|
|
31
|
+
File.write(path, JSON.pretty_generate(JSON.parse(object.to_json)))
|
|
32
|
+
component(name: name, file: file, media_type: "application/json")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Line-oriented component; the block serializes each item
|
|
36
|
+
# (framework to_json by default).
|
|
37
|
+
def add_lines(name, file, items, &serializer)
|
|
38
|
+
serializer ||= lambda(&:to_json)
|
|
39
|
+
path = File.join(dir, file)
|
|
40
|
+
File.write(path, "#{items.map(&serializer).join("\n")}\n")
|
|
41
|
+
component(name: name, file: file, media_type: "application/jsonl",
|
|
42
|
+
count: items.size)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_asset(entry)
|
|
46
|
+
path = File.join(dir, entry.name)
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
48
|
+
File.binwrite(path, entry.data)
|
|
49
|
+
component(name: entry.name, file: entry.name,
|
|
50
|
+
media_type: entry.media_type, count: 1)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def flavor=(flavor)
|
|
54
|
+
@manifest.generated.flavor = flavor
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def source_file=(source)
|
|
58
|
+
@manifest.source_file = source
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def write_manifest
|
|
62
|
+
@manifest.generated.timestamp = Time.now.utc.iso8601
|
|
63
|
+
File.write(File.join(dir, "manifest.json"),
|
|
64
|
+
JSON.pretty_generate(JSON.parse(@manifest.to_json)))
|
|
65
|
+
dir
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def zip!
|
|
69
|
+
require "zip"
|
|
70
|
+
zip_path = "#{dir}.zip"
|
|
71
|
+
Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
|
|
72
|
+
Dir[File.join(dir, "**", "*")].each do |f|
|
|
73
|
+
next if File.directory?(f)
|
|
74
|
+
|
|
75
|
+
zipfile.add(f.sub("#{dir}/", ""), f)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
zip_path
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def component(name:, file:, media_type:, count: nil)
|
|
84
|
+
@manifest.components << Schema::ManifestComponent.new(
|
|
85
|
+
name: name, file: file, media_type: media_type,
|
|
86
|
+
count: count, hash: "sha256:#{Digest::SHA256.file(File.join(dir, file)).hexdigest}"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Mko
|
|
7
|
+
# Structured edition diffs (MN 116 §diffs; issue #53 item 5):
|
|
8
|
+
# "what changed between the 2000 and the 2017 edition" becomes a
|
|
9
|
+
# machine answer, not an expert reading marathon. Built on the
|
|
10
|
+
# stable-id contract — units pair by anchor, content hashes say
|
|
11
|
+
# whether anything changed — so a diff is exact and incremental.
|
|
12
|
+
module Diff
|
|
13
|
+
class << self
|
|
14
|
+
# Two bundles of the same document (different editions).
|
|
15
|
+
# Returns a change set:
|
|
16
|
+
# { from:, to:, added: [unit summaries], removed: [...],
|
|
17
|
+
# changed: [{ anchor, type, number, fields: [...] }] }
|
|
18
|
+
# fields carries field-level detail (text, title, number, and
|
|
19
|
+
# typed payload fields: designations, definition, rows,
|
|
20
|
+
# statement, …).
|
|
21
|
+
def between(bundle_a, bundle_b)
|
|
22
|
+
a = units_by_anchor(bundle_a)
|
|
23
|
+
b = units_by_anchor(bundle_b)
|
|
24
|
+
{
|
|
25
|
+
"from" => document_id(bundle_a),
|
|
26
|
+
"to" => document_id(bundle_b),
|
|
27
|
+
"added" => (b.keys - a.keys).map { |k| summary(b[k]) },
|
|
28
|
+
"removed" => (a.keys - b.keys).map { |k| summary(a[k]) },
|
|
29
|
+
"changed" => (a.keys & b.keys).filter_map do |k|
|
|
30
|
+
next if a[k]["hash"] == b[k]["hash"]
|
|
31
|
+
|
|
32
|
+
changed_unit(a[k], b[k])
|
|
33
|
+
end,
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Write <dir>/<from>-to-<to>.diff.json; returns the path.
|
|
38
|
+
def export(bundle_a, bundle_b, to:)
|
|
39
|
+
diff = between(bundle_a, bundle_b)
|
|
40
|
+
name = "#{Mko.slug(diff["from"])}-to-#{Mko.slug(diff["to"])}.diff.json"
|
|
41
|
+
path = File.join(to, name)
|
|
42
|
+
File.write(path, JSON.pretty_generate(diff) + "\n")
|
|
43
|
+
path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def units_by_anchor(bundle)
|
|
49
|
+
File.readlines(File.join(bundle, "units.jsonl"))
|
|
50
|
+
.each_with_object({}) do |line, h|
|
|
51
|
+
unit = JSON.parse(line)
|
|
52
|
+
h[unit["anchor"]] = unit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def document_id(bundle)
|
|
57
|
+
JSON.parse(File.read(File.join(bundle, "document.json")))
|
|
58
|
+
.dig("ids", "canonical")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def summary(unit)
|
|
62
|
+
{ "anchor" => unit["anchor"], "type" => unit["type"],
|
|
63
|
+
"number" => unit["number"], "title" => unit["title"] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Field-level detail: top-level fields first, then the payload
|
|
67
|
+
# sub-fields consumers act on (terminology changes, table row
|
|
68
|
+
# deltas, requirement restatements).
|
|
69
|
+
def changed_unit(old, new)
|
|
70
|
+
fields = %w[text title number obligation].select do |f|
|
|
71
|
+
old[f] != new[f]
|
|
72
|
+
end
|
|
73
|
+
fields.concat(payload_changes(old, new))
|
|
74
|
+
summary(new).merge("fields" => fields.uniq)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def payload_changes(old, new)
|
|
78
|
+
op = old["payload"] || {}
|
|
79
|
+
np = new["payload"] || {}
|
|
80
|
+
return [] if op == np
|
|
81
|
+
|
|
82
|
+
changed = []
|
|
83
|
+
%w[designations admitted deprecated definition statement
|
|
84
|
+
subject identifier klass caption columns].each do |f|
|
|
85
|
+
changed << f if op[f] != np[f]
|
|
86
|
+
end
|
|
87
|
+
if op["rows"] != np["rows"]
|
|
88
|
+
changed << "rows" \
|
|
89
|
+
"(+#{(np["rows"] || []).size - (op["rows"] || []).size})"
|
|
90
|
+
end
|
|
91
|
+
changed << "payload" if changed.empty? && op != np
|
|
92
|
+
changed
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Mko
|
|
5
|
+
# The value an export returns: the bundle path (String-ducktyped,
|
|
6
|
+
# so existing callers keep working) plus the projection Result,
|
|
7
|
+
# so composite exporters (collections, alignments) derive from
|
|
8
|
+
# the same truth the bundle was written from — never by reading
|
|
9
|
+
# their own output back.
|
|
10
|
+
class Export
|
|
11
|
+
attr_reader :path, :result
|
|
12
|
+
|
|
13
|
+
def initialize(path, result)
|
|
14
|
+
@path = path
|
|
15
|
+
@result = result
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
@path.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_str
|
|
23
|
+
@path.to_s
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Mko
|
|
7
|
+
# Reference MCP server over an MKO bundle (issue #53 item 9; the
|
|
8
|
+
# MN 116 contract: search_units / get_unit / walk_edges /
|
|
9
|
+
# edition_diff). Every tool is a read over bundle files — an agent
|
|
10
|
+
# consumes a Metanorma corpus without anyone building a RAG first.
|
|
11
|
+
# JSON-RPC 2.0 over stdio; drive via #handle lines or #run(io).
|
|
12
|
+
module Mcp
|
|
13
|
+
class Server
|
|
14
|
+
PROTOCOL = "2025-06-18"
|
|
15
|
+
TOOLS = [
|
|
16
|
+
{ "name" => "search_units",
|
|
17
|
+
"description" => "Search the document's units by term " \
|
|
18
|
+
"overlap over title and text; optional type filter",
|
|
19
|
+
"inputSchema" => {
|
|
20
|
+
"type" => "object",
|
|
21
|
+
"properties" => {
|
|
22
|
+
"query" => { "type" => "string" },
|
|
23
|
+
"type" => { "type" => "string",
|
|
24
|
+
"enum" => %w[clause annex term table figure formula
|
|
25
|
+
note example sourcecode requirement
|
|
26
|
+
reference] },
|
|
27
|
+
},
|
|
28
|
+
"required" => %w[query],
|
|
29
|
+
} },
|
|
30
|
+
{ "name" => "get_unit",
|
|
31
|
+
"description" => "Full unit by stable id (text + typed payload)",
|
|
32
|
+
"inputSchema" => {
|
|
33
|
+
"type" => "object",
|
|
34
|
+
"properties" => { "id" => { "type" => "string" } },
|
|
35
|
+
"required" => %w[id],
|
|
36
|
+
} },
|
|
37
|
+
{ "name" => "walk_edges",
|
|
38
|
+
"description" => "Edges touching a unit (any kind, or one kind)",
|
|
39
|
+
"inputSchema" => {
|
|
40
|
+
"type" => "object",
|
|
41
|
+
"properties" => {
|
|
42
|
+
"unit_id" => { "type" => "string" },
|
|
43
|
+
"kind" => { "type" => "string" },
|
|
44
|
+
},
|
|
45
|
+
} },
|
|
46
|
+
{ "name" => "edition_diff",
|
|
47
|
+
"description" => "Structured change set between two bundles of " \
|
|
48
|
+
"the same document (anchors pair; hashes decide)",
|
|
49
|
+
"inputSchema" => {
|
|
50
|
+
"type" => "object",
|
|
51
|
+
"properties" => {
|
|
52
|
+
"bundle_a" => { "type" => "string" },
|
|
53
|
+
"bundle_b" => { "type" => "string" },
|
|
54
|
+
},
|
|
55
|
+
"required" => %w[bundle_a bundle_b],
|
|
56
|
+
} },
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
def initialize(bundle)
|
|
60
|
+
@bundle = bundle
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One JSON-RPC request line -> response line (or nil for
|
|
64
|
+
# notifications). The test surface: drive the protocol without
|
|
65
|
+
# spawning a process.
|
|
66
|
+
def handle(line)
|
|
67
|
+
req = JSON.parse(line)
|
|
68
|
+
return nil if req["id"].nil?
|
|
69
|
+
|
|
70
|
+
result =
|
|
71
|
+
case req["method"]
|
|
72
|
+
when "initialize"
|
|
73
|
+
{ "protocolVersion" => PROTOCOL,
|
|
74
|
+
"capabilities" => { "tools" => {} },
|
|
75
|
+
"serverInfo" => { "name" => "metanorma-mko",
|
|
76
|
+
"version" => SCHEMA_VERSION } }
|
|
77
|
+
when "tools/list" then { "tools" => TOOLS }
|
|
78
|
+
when "tools/call" then call_tool(req["params"] || {})
|
|
79
|
+
end
|
|
80
|
+
return JSON.generate(
|
|
81
|
+
{ "jsonrpc" => "2.0", "id" => req["id"],
|
|
82
|
+
"error" => { "code" => -32_601,
|
|
83
|
+
"message" => "method not found: #{req['method']}" } }
|
|
84
|
+
) if result.nil?
|
|
85
|
+
|
|
86
|
+
JSON.generate({ "jsonrpc" => "2.0", "id" => req["id"],
|
|
87
|
+
"result" => result })
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
JSON.generate({ "jsonrpc" => "2.0", "id" => (req && req["id"]),
|
|
90
|
+
"error" => { "code" => -32_603,
|
|
91
|
+
"message" => "#{e.class}: #{e.message}" } })
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run(io = $stdin)
|
|
95
|
+
io.each_line { |line| (out = handle(line)) && puts(out) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def units
|
|
101
|
+
@units ||= File.readlines(File.join(@bundle, "units.jsonl"))
|
|
102
|
+
.map { |l| JSON.parse(l) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def edges
|
|
106
|
+
@edges ||= File.readlines(File.join(@bundle, "edges.jsonl"))
|
|
107
|
+
.map { |l| JSON.parse(l) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def call_tool(params)
|
|
111
|
+
name = params["name"]
|
|
112
|
+
args = params["arguments"] || {}
|
|
113
|
+
case name
|
|
114
|
+
when "search_units" then search_units(args)
|
|
115
|
+
when "get_unit" then get_unit(args)
|
|
116
|
+
when "walk_edges" then walk_edges(args)
|
|
117
|
+
when "edition_diff" then edition_diff(args)
|
|
118
|
+
else raise ArgumentError, "unknown tool: #{name}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def search_units(args)
|
|
123
|
+
terms = args["query"].to_s.downcase.split(/[^\p{L}\p{N}]+/)
|
|
124
|
+
.reject { |t| t.length < 2 }
|
|
125
|
+
pool = args["type"] ? units.select { |u| u["type"] == args["type"] } : units
|
|
126
|
+
scored = pool.filter_map do |u|
|
|
127
|
+
hay = "#{u['title']} #{u['text']}".downcase
|
|
128
|
+
score = terms.count { |t| hay.include?(t) }
|
|
129
|
+
{ "id" => u["id"], "type" => u["type"], "anchor" => u["anchor"],
|
|
130
|
+
"number" => u["number"], "title" => u["title"],
|
|
131
|
+
"score" => score } if score.positive?
|
|
132
|
+
end
|
|
133
|
+
text(JSON.generate(scored.sort_by { |s| -s["score"] }.first(10)))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def get_unit(args)
|
|
137
|
+
unit = units.find { |u| u["id"] == args["id"] }
|
|
138
|
+
raise ArgumentError, "no unit #{args['id']}" unless unit
|
|
139
|
+
|
|
140
|
+
text(JSON.generate(unit))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def walk_edges(args)
|
|
144
|
+
picked = edges.select do |e|
|
|
145
|
+
(args["unit_id"].nil? || e["from"] == args["unit_id"] ||
|
|
146
|
+
e["to"] == args["unit_id"]) &&
|
|
147
|
+
(args["kind"].nil? || e["kind"] == args["kind"])
|
|
148
|
+
end
|
|
149
|
+
text(JSON.generate(picked.first(50)))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def edition_diff(args)
|
|
153
|
+
text(JSON.generate(Diff.between(args["bundle_a"], args["bundle_b"])))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def text(body)
|
|
157
|
+
{ "content" => [{ "type" => "text", "text" => body }] }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Mko
|
|
5
|
+
# The in-memory bundle: what a projection produces and the Writer
|
|
6
|
+
# serializes. Format-shaped — the component set of MN 116 — so it
|
|
7
|
+
# lives with the format, constructed by the model-side walk in
|
|
8
|
+
# metanorma-document.
|
|
9
|
+
class Result
|
|
10
|
+
attr_reader :document, :units, :edges, :glossary, :bibdata,
|
|
11
|
+
:bibliography, :identifiers, :assets, :unitsml, :flavor
|
|
12
|
+
|
|
13
|
+
def initialize(document:, units:, edges:, glossary:, bibdata:,
|
|
14
|
+
bibliography:, identifiers:, assets: [], unitsml: [],
|
|
15
|
+
flavor:)
|
|
16
|
+
@document = document
|
|
17
|
+
@units = units
|
|
18
|
+
@edges = edges
|
|
19
|
+
@glossary = glossary
|
|
20
|
+
@bibdata = bibdata
|
|
21
|
+
@bibliography = bibliography
|
|
22
|
+
@identifiers = identifiers
|
|
23
|
+
@assets = assets
|
|
24
|
+
@unitsml = unitsml
|
|
25
|
+
@flavor = flavor
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Mko
|
|
5
|
+
module Schema
|
|
6
|
+
# collection.json — the family contract for collection bundles:
|
|
7
|
+
# membership and cross-document edges between documents that a
|
|
8
|
+
# metanorma collection.yml declares together (OIML R 60 parts,
|
|
9
|
+
# R 129/138/144 families).
|
|
10
|
+
class CollectionMember < Lutaml::Model::Serializable
|
|
11
|
+
attribute :bundle, :string
|
|
12
|
+
attribute :docidentifier, :string
|
|
13
|
+
attribute :identifier, :string
|
|
14
|
+
attribute :title, :string
|
|
15
|
+
attribute :edition, :string
|
|
16
|
+
|
|
17
|
+
json do
|
|
18
|
+
map "bundle", to: :bundle
|
|
19
|
+
map "docidentifier", to: :docidentifier
|
|
20
|
+
map "identifier", to: :identifier
|
|
21
|
+
map "title", to: :title
|
|
22
|
+
map "edition", to: :edition
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Collection < Lutaml::Model::Serializable
|
|
27
|
+
attribute :canonical, :string
|
|
28
|
+
attribute :short, :string
|
|
29
|
+
attribute :title, :string
|
|
30
|
+
attribute :edition, :string
|
|
31
|
+
attribute :members, CollectionMember, collection: true,
|
|
32
|
+
default: -> { [] }
|
|
33
|
+
|
|
34
|
+
json do
|
|
35
|
+
map "canonical", to: :canonical
|
|
36
|
+
map "short", to: :short
|
|
37
|
+
map "title", to: :title
|
|
38
|
+
map "edition", to: :edition
|
|
39
|
+
map "members", to: :members
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|