rulegrid-editor 0.1.0__tar.gz
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.
- rulegrid_editor-0.1.0/.gitignore +8 -0
- rulegrid_editor-0.1.0/LICENSE +21 -0
- rulegrid_editor-0.1.0/PKG-INFO +50 -0
- rulegrid_editor-0.1.0/README.md +38 -0
- rulegrid_editor-0.1.0/pyproject.toml +23 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/__init__.py +33 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/server.py +365 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/app.js +181 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/forms.js +121 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/index.html +13 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/pages.js +171 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/style.css +119 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/try.js +112 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/ui.js +96 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/vendor/HTM-LICENSE +202 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/vendor/PREACT-LICENSE +21 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/vendor/hooks.js +2 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/vendor/htm.js +2 -0
- rulegrid_editor-0.1.0/src/rulegrid_editor/static/vendor/preact.js +2 -0
- rulegrid_editor-0.1.0/tests/test_editor.py +75 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Félix Martel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rulegrid-editor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Browse, edit and try a rulegrid policy in the browser.
|
|
5
|
+
Author: Félix Martel
|
|
6
|
+
Author-email: Grégoire Lothe <51675587+glothe@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Requires-Dist: rulegrid>=0.1.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# rulegrid-editor
|
|
14
|
+
|
|
15
|
+
Browse, edit and try a [rulegrid](https://github.com/the-marshmallow-project/rulegrid) policy in the browser, with
|
|
16
|
+
one command:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx rulegrid-editor PROFILE
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The page mirrors the policy's concepts: its
|
|
23
|
+
profile (context and principles), its structure, its turn and response axes and their values, its tiers and their
|
|
24
|
+
grids of cells, its prohibitions and its lexicons. Every id links to its definition and previews it on hover.
|
|
25
|
+
|
|
26
|
+
- **Document** reads the policy as people do.
|
|
27
|
+
- Each concept's page edits it with forms built from the profile's JSON Schema; a tier's grid opens a cell's form.
|
|
28
|
+
The structure is read-only: edit `structure.yaml` and run `rulegrid generate`.
|
|
29
|
+
- **Changes** compares the policy with git `HEAD`, article by article and line by line.
|
|
30
|
+
- **YAML** edits the files as text.
|
|
31
|
+
- The **Try** panel classifies a turn and a response, and shows the cell they land in, the nudge and the compliance.
|
|
32
|
+
|
|
33
|
+
Edits stay unsaved until **Save** (or Ctrl+S), which writes the files in place and keeps their comments.
|
|
34
|
+
The server binds to loopback and has no authentication; do not expose it without an authenticated proxy.
|
|
35
|
+
|
|
36
|
+
The try panel classifies with `--classifier`, which reads its endpoint and key from the environment, as
|
|
37
|
+
`rulegrid eval` does. OpenRouter serves both built-in classifiers:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export RULEGRID_SYSTEMONE_URL=https://openrouter.ai/api RULEGRID_SYSTEMONE_KEY=$OPENROUTER_API_KEY
|
|
41
|
+
rulegrid-editor examples/rbr/profiles/default --classifier systemone:liquid/d1-ultra --threshold alert=0.2
|
|
42
|
+
|
|
43
|
+
export RULEGRID_LLM_URL=https://openrouter.ai/api/v1 RULEGRID_LLM_KEY=$OPENROUTER_API_KEY
|
|
44
|
+
rulegrid-editor examples/three_laws/profiles/default --classifier judge:openai/gpt-5-mini
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without one, the panel takes the values set by hand.
|
|
48
|
+
|
|
49
|
+
The page is [Preact](https://preactjs.com) and [htm](https://github.com/developit/htm), vendored in
|
|
50
|
+
`static/vendor` (7 KB gzipped): there is no build step and nothing to install from npm.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# rulegrid-editor
|
|
2
|
+
|
|
3
|
+
Browse, edit and try a [rulegrid](https://github.com/the-marshmallow-project/rulegrid) policy in the browser, with
|
|
4
|
+
one command:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uvx rulegrid-editor PROFILE
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The page mirrors the policy's concepts: its
|
|
11
|
+
profile (context and principles), its structure, its turn and response axes and their values, its tiers and their
|
|
12
|
+
grids of cells, its prohibitions and its lexicons. Every id links to its definition and previews it on hover.
|
|
13
|
+
|
|
14
|
+
- **Document** reads the policy as people do.
|
|
15
|
+
- Each concept's page edits it with forms built from the profile's JSON Schema; a tier's grid opens a cell's form.
|
|
16
|
+
The structure is read-only: edit `structure.yaml` and run `rulegrid generate`.
|
|
17
|
+
- **Changes** compares the policy with git `HEAD`, article by article and line by line.
|
|
18
|
+
- **YAML** edits the files as text.
|
|
19
|
+
- The **Try** panel classifies a turn and a response, and shows the cell they land in, the nudge and the compliance.
|
|
20
|
+
|
|
21
|
+
Edits stay unsaved until **Save** (or Ctrl+S), which writes the files in place and keeps their comments.
|
|
22
|
+
The server binds to loopback and has no authentication; do not expose it without an authenticated proxy.
|
|
23
|
+
|
|
24
|
+
The try panel classifies with `--classifier`, which reads its endpoint and key from the environment, as
|
|
25
|
+
`rulegrid eval` does. OpenRouter serves both built-in classifiers:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export RULEGRID_SYSTEMONE_URL=https://openrouter.ai/api RULEGRID_SYSTEMONE_KEY=$OPENROUTER_API_KEY
|
|
29
|
+
rulegrid-editor examples/rbr/profiles/default --classifier systemone:liquid/d1-ultra --threshold alert=0.2
|
|
30
|
+
|
|
31
|
+
export RULEGRID_LLM_URL=https://openrouter.ai/api/v1 RULEGRID_LLM_KEY=$OPENROUTER_API_KEY
|
|
32
|
+
rulegrid-editor examples/three_laws/profiles/default --classifier judge:openai/gpt-5-mini
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Without one, the panel takes the values set by hand.
|
|
36
|
+
|
|
37
|
+
The page is [Preact](https://preactjs.com) and [htm](https://github.com/developit/htm), vendored in
|
|
38
|
+
`static/vendor` (7 KB gzipped): there is no build step and nothing to install from npm.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rulegrid-editor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Browse, edit and try a rulegrid policy in the browser."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Félix Martel" },
|
|
10
|
+
{ name = "Grégoire Lothe", email = "51675587+glothe@users.noreply.github.com" },
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.13"
|
|
13
|
+
dependencies = ["rulegrid>=0.1.0"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
rulegrid-editor = "rulegrid_editor:main"
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
rulegrid = { workspace = true }
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""rulegrid-editor: browse, edit and try a rulegrid policy in the browser.
|
|
2
|
+
|
|
3
|
+
PROFILE is a profile directory, such as `examples/rbr/profiles/default`. The try panel's classifier reads its endpoint
|
|
4
|
+
and key from the environment, as `rulegrid eval` does.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from rulegrid.cli import PROFILE_HELP, classifier
|
|
14
|
+
from rulegrid.policy import PolicyError
|
|
15
|
+
from rulegrid_editor.server import edit, serve
|
|
16
|
+
|
|
17
|
+
__all__ = ["edit", "main", "serve"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="rulegrid-editor", description=(__doc__ or "").split("\n\n")[0])
|
|
22
|
+
parser.add_argument("profile", metavar="PROFILE", help=PROFILE_HELP)
|
|
23
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
24
|
+
parser.add_argument("--classifier", help="the try panel's: systemone[:MODEL], judge:MODEL or module:attr")
|
|
25
|
+
parser.add_argument("--threshold", action="append", default=[], metavar="TIER=P", help="SystemOne tiers")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
thresholds = {t: float(p) for t, p in (t.split("=") for t in args.threshold)}
|
|
28
|
+
try:
|
|
29
|
+
serve(args.profile, args.port, args.classifier and classifier(args.classifier, thresholds))
|
|
30
|
+
except PolicyError as error:
|
|
31
|
+
print(error, file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
return 0
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The editor's server: a stdlib web server over one profile, and the page in `static/`.
|
|
2
|
+
|
|
3
|
+
Every view runs on the page's unsaved text: the server loads the policy from it, checks it, compares it with git `HEAD`
|
|
4
|
+
and tries a turn on it. Every edit goes through `edit`, which rewrites only what changed, so comments and layout stay.
|
|
5
|
+
Saving writes the files in place, and git is the versioning. It binds to localhost and has no auth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import difflib
|
|
13
|
+
import json
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import weakref
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import asdict
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from importlib.resources import files as package_files
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Literal
|
|
27
|
+
|
|
28
|
+
import yamlrocks
|
|
29
|
+
|
|
30
|
+
from rulegrid.check import check
|
|
31
|
+
from rulegrid.diff import diff
|
|
32
|
+
from rulegrid.generate import profile_schema
|
|
33
|
+
from rulegrid.lexicon import Entry, match
|
|
34
|
+
from rulegrid.policy import PROFILE, STRUCTURE, Policy, PolicyError, load
|
|
35
|
+
from rulegrid.render import nudge
|
|
36
|
+
from rulegrid.rules import comply, prescribe
|
|
37
|
+
from rulegrid.types import Classification, Classifier, Message
|
|
38
|
+
|
|
39
|
+
Path_ = list[str | int]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def edit(text: str, path: Path_, value: Any) -> str:
|
|
43
|
+
"""`text`, a YAML document, with the node at `path` set to `value`, or removed when `value` is None.
|
|
44
|
+
|
|
45
|
+
Only the keys and items that differ are rewritten, so the comments and layout around them stay; a mapping missing
|
|
46
|
+
on the way is created. A list keeps its layout when items are removed or appended, as the page does.
|
|
47
|
+
"""
|
|
48
|
+
doc = yamlrocks.loads(text, option=yamlrocks.OPT_ROUND_TRIP)
|
|
49
|
+
parent, old = doc, yamlrocks.loads(text) or {}
|
|
50
|
+
for key in path[:-1]:
|
|
51
|
+
if not _has(old, key) or old[key] is None:
|
|
52
|
+
parent[key], old[key] = {}, {}
|
|
53
|
+
parent, old = parent[key], old[key]
|
|
54
|
+
splices: list[tuple[Path_, list[Any]]] = []
|
|
55
|
+
if not path: # the whole file
|
|
56
|
+
_merge({"": doc}, "", old, value, [], splices)
|
|
57
|
+
elif value is None:
|
|
58
|
+
if _has(old, path[-1]):
|
|
59
|
+
del parent[path[-1]]
|
|
60
|
+
elif _has(old, path[-1]):
|
|
61
|
+
_merge(parent, path[-1], old[path[-1]], value, path, splices)
|
|
62
|
+
else:
|
|
63
|
+
parent[path[-1]] = value
|
|
64
|
+
out = yamlrocks.dumps(doc, option=yamlrocks.OPT_ROUND_TRIP)
|
|
65
|
+
for at, items in splices:
|
|
66
|
+
out = _splice(out, at, items)
|
|
67
|
+
return out.decode()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _has(node: Any, key: str | int) -> bool:
|
|
71
|
+
return 0 <= key < len(node) if isinstance(node, list) and isinstance(key, int) else key in node
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _merge(parent: Any, key: str | int, old: Any, new: Any, at: Path_, splices: list[tuple[Path_, list[Any]]]) -> None:
|
|
75
|
+
"""Set `parent[key]`, which holds `old`, to `new`, descending into what both share.
|
|
76
|
+
|
|
77
|
+
A list that grows by its end, or flow-style, is left to a splice of the dumped text: assigning a whole list would
|
|
78
|
+
re-indent it and drop its comments.
|
|
79
|
+
"""
|
|
80
|
+
if old == new:
|
|
81
|
+
return
|
|
82
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
83
|
+
node = parent[key]
|
|
84
|
+
for k in [k for k in old if k not in new]:
|
|
85
|
+
del node[k]
|
|
86
|
+
for k, v in new.items():
|
|
87
|
+
if k in old:
|
|
88
|
+
_merge(node, k, old[k], v, [*at, k], splices)
|
|
89
|
+
else:
|
|
90
|
+
node[k] = v
|
|
91
|
+
return
|
|
92
|
+
if not (isinstance(old, list) and isinstance(new, list) and old):
|
|
93
|
+
parent[key] = new
|
|
94
|
+
return
|
|
95
|
+
if parent[key].node.style == "flow":
|
|
96
|
+
splices.append((at, new))
|
|
97
|
+
return
|
|
98
|
+
keys = [json.dumps(x, sort_keys=True) for x in old], [json.dumps(x, sort_keys=True) for x in new]
|
|
99
|
+
ops = difflib.SequenceMatcher(None, *keys, autojunk=False).get_opcodes()
|
|
100
|
+
if all(op in ("equal", "delete") for op, *_ in ops): # items removed: delete them, and their comments stay put
|
|
101
|
+
for op, i1, i2, *_ in reversed(ops):
|
|
102
|
+
for i in reversed(range(i1, i2) if op == "delete" else ()):
|
|
103
|
+
del parent[key][i]
|
|
104
|
+
return
|
|
105
|
+
if len(new) < len(old):
|
|
106
|
+
parent[key] = new
|
|
107
|
+
return
|
|
108
|
+
for i, (a, b) in enumerate(zip(old, new[: len(old)], strict=True)):
|
|
109
|
+
_merge(parent[key], i, a, b, [*at, i], splices)
|
|
110
|
+
if len(new) > len(old):
|
|
111
|
+
splices.append((at, new[len(old) :]))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _splice(text: bytes, at: Path_, items: list[Any]) -> bytes:
|
|
115
|
+
"""`text` with the flow list at `at` set to `items`, or the block list at `at` followed by `items`."""
|
|
116
|
+
node = yamlrocks.loads(text, option=yamlrocks.OPT_ROUND_TRIP)
|
|
117
|
+
for key in at:
|
|
118
|
+
node = node[key]
|
|
119
|
+
if node.node.style == "flow":
|
|
120
|
+
start, end = node.node.offset, text.index(b"]", node.node.end_offset - 1) + 1
|
|
121
|
+
flow = yamlrocks.dumps(items, option=yamlrocks.OPT_FLOW_STYLE).rstrip()
|
|
122
|
+
return text[:start] + flow + text[end:]
|
|
123
|
+
last = node[len(node.unwrap()) - 1].node
|
|
124
|
+
indent = " " * (last.column - 3) # the column of the item's content, past its "- "
|
|
125
|
+
end = text.find(b"\n", last.end_offset) + 1 or len(text)
|
|
126
|
+
block = "".join(indent + line for line in yamlrocks.dumps(items).decode().splitlines(keepends=True))
|
|
127
|
+
return text[:end] + block.encode() + text[end:]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Workspace:
|
|
131
|
+
"""The policy's files on disk, and the policy they make with the page's unsaved text over them.
|
|
132
|
+
|
|
133
|
+
Unsaved text is loaded from a mirror of the policy's folder, which the lock guards: a `Policy` reads its templates
|
|
134
|
+
and generated files from its folder.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, path: Path, classifier: Callable[[Policy], Classifier] | None):
|
|
138
|
+
policy = load(path)
|
|
139
|
+
profile_dir = policy.profile_dir.resolve()
|
|
140
|
+
self.base = Path(os.path.commonpath([policy.root, profile_dir]))
|
|
141
|
+
self.profile = profile_dir.relative_to(self.base)
|
|
142
|
+
names = [policy.root / STRUCTURE, profile_dir / PROFILE, *sorted(profile_dir.glob("lexicons/*.yaml"))]
|
|
143
|
+
self.names = [str(p.relative_to(self.base)) for p in names]
|
|
144
|
+
self.classifier = classifier
|
|
145
|
+
directory = tempfile.TemporaryDirectory(prefix="rulegrid-editor-")
|
|
146
|
+
self.mirror = Path(directory.name) / self.base.name
|
|
147
|
+
weakref.finalize(self, directory.cleanup)
|
|
148
|
+
self.lock = threading.Lock()
|
|
149
|
+
self.head: tuple[str, Policy, dict[str, str]] | None = None
|
|
150
|
+
|
|
151
|
+
def files(self) -> dict[str, str]:
|
|
152
|
+
return {name: (self.base / name).read_text() for name in self.names}
|
|
153
|
+
|
|
154
|
+
def _texts(self, texts: Mapping[str, str]) -> dict[str, str]:
|
|
155
|
+
if unknown := [name for name in texts if name not in self.names]:
|
|
156
|
+
raise ValueError(f"not a file of this policy: {', '.join(unknown)}")
|
|
157
|
+
return dict(texts)
|
|
158
|
+
|
|
159
|
+
def save(self, texts: Mapping[str, str]) -> None:
|
|
160
|
+
with self.lock:
|
|
161
|
+
for name, text in self._texts(texts).items():
|
|
162
|
+
(self.base / name).write_text(text)
|
|
163
|
+
|
|
164
|
+
def load(self, texts: Mapping[str, str]) -> Policy:
|
|
165
|
+
"""The policy with `texts` over the files on disk. Call it with the lock held."""
|
|
166
|
+
texts = self._texts(texts)
|
|
167
|
+
shutil.copytree(self.base, self.mirror, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".*", "__pycache__"))
|
|
168
|
+
for name, text in texts.items():
|
|
169
|
+
(self.mirror / name).write_text(text)
|
|
170
|
+
return load(self.mirror / self.profile)
|
|
171
|
+
|
|
172
|
+
def changes(self, policy: Policy, texts: Mapping[str, str]) -> dict[str, Any]:
|
|
173
|
+
"""What changed from git `HEAD`: the articles, and each file's lines."""
|
|
174
|
+
git = ["git", "-C", str(self.base)]
|
|
175
|
+
try:
|
|
176
|
+
sha = subprocess.check_output([*git, "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL).strip()
|
|
177
|
+
if self.head is None or self.head[0] != sha:
|
|
178
|
+
show = [*git, "show"]
|
|
179
|
+
old = {n: subprocess.check_output([*show, f"{sha}:./{n}"], text=True) for n in self.names}
|
|
180
|
+
self.head = sha, load(self.base / self.profile, revision=sha), old
|
|
181
|
+
except (subprocess.CalledProcessError, OSError, PolicyError):
|
|
182
|
+
return {"error": "no version at git HEAD to compare with"}
|
|
183
|
+
_, before, old = self.head
|
|
184
|
+
lines = {
|
|
185
|
+
n: list(difflib.unified_diff(old[n].splitlines(), t.splitlines(), "HEAD", "now", lineterm=""))[2:]
|
|
186
|
+
for n, t in texts.items()
|
|
187
|
+
}
|
|
188
|
+
return {"articles": str(diff(before, policy)), "files": {n: ls for n, ls in lines.items() if ls}}
|
|
189
|
+
|
|
190
|
+
def view(self, texts: Mapping[str, str]) -> dict[str, Any]:
|
|
191
|
+
with self.lock:
|
|
192
|
+
try:
|
|
193
|
+
policy = self.load(texts)
|
|
194
|
+
except PolicyError as error:
|
|
195
|
+
return {"problems": [str(p) for p in error.problems]}
|
|
196
|
+
current = {**self.files(), **texts}
|
|
197
|
+
return {
|
|
198
|
+
"problems": [str(p) for p in check(policy)],
|
|
199
|
+
"policy": _outline(policy),
|
|
200
|
+
"changes": self.changes(policy, current),
|
|
201
|
+
"schemas": _schemas(policy),
|
|
202
|
+
"data": {name: yamlrocks.loads(text) for name, text in current.items()},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def edit(self, texts: Mapping[str, str], file: str, path: Path_, value: Any) -> str:
|
|
206
|
+
self._texts({file: ""})
|
|
207
|
+
with self.lock:
|
|
208
|
+
return edit(texts[file] if file in texts else (self.base / file).read_text(), path, value)
|
|
209
|
+
|
|
210
|
+
def attempt(self, texts: Mapping[str, str], request: dict[str, Any]) -> dict[str, Any]:
|
|
211
|
+
"""Read a turn and maybe a response: classified from their text, or from the values the page sets."""
|
|
212
|
+
turn_text, response_text = request.get("turn", ""), request.get("response", "")
|
|
213
|
+
with self.lock:
|
|
214
|
+
policy = self.load(texts)
|
|
215
|
+
classifier = self.classifier(policy) if self.classifier else None
|
|
216
|
+
hits = match(policy.lexicons, turn_text)
|
|
217
|
+
convo = [Message("user", turn_text)]
|
|
218
|
+
|
|
219
|
+
async def read(of: Literal["turn", "response"], values: dict[str, Any] | None) -> Classification:
|
|
220
|
+
if values is not None:
|
|
221
|
+
return Classification({k: frozenset(v) if isinstance(v, list) else v for k, v in values.items()})
|
|
222
|
+
if classifier is None:
|
|
223
|
+
raise ValueError("no classifier: set the values, or start the editor with --classifier")
|
|
224
|
+
if not turn_text.strip():
|
|
225
|
+
raise ValueError("write a turn to classify")
|
|
226
|
+
if of == "turn":
|
|
227
|
+
return await classifier.classify(convo, "turn", hits)
|
|
228
|
+
return await classifier.classify([*convo, Message("assistant", response_text)], "response")
|
|
229
|
+
|
|
230
|
+
async def both() -> tuple[Classification, Classification | None]:
|
|
231
|
+
turn = read("turn", request.get("turn_values"))
|
|
232
|
+
if request.get("response_values") is None and not response_text:
|
|
233
|
+
return await turn, None
|
|
234
|
+
return await asyncio.gather(turn, read("response", request.get("response_values")))
|
|
235
|
+
|
|
236
|
+
turn, response = asyncio.run(both())
|
|
237
|
+
# Values set by hand can leave axes unset: the page shows them, and the compliance once every axis is set.
|
|
238
|
+
missing = [a for a in policy.turn_axes if a not in turn.values]
|
|
239
|
+
complete = response is not None and all(a in response.values for a in policy.response_axes)
|
|
240
|
+
with self.lock:
|
|
241
|
+
text = None if missing else nudge(policy, turn, hits)
|
|
242
|
+
prescription = None if missing else prescribe(policy, turn)
|
|
243
|
+
return {
|
|
244
|
+
"turn": _classification(turn),
|
|
245
|
+
"missing": missing,
|
|
246
|
+
"hits": [{"text": h.text, "meaning": h.entry.meaning, "reminds": h.reminds} for h in hits],
|
|
247
|
+
"prescription": prescription and asdict(prescription),
|
|
248
|
+
"nudge": text,
|
|
249
|
+
"response": response and _classification(response),
|
|
250
|
+
"compliance": response and complete and not missing and asdict(comply(policy, turn, response)),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _schemas(policy: Policy) -> dict[str, Any]:
|
|
255
|
+
"""The JSON Schemas the page builds its forms from: the profile's, and a lexicon file's."""
|
|
256
|
+
lexicon = {
|
|
257
|
+
"type": "object",
|
|
258
|
+
"properties": {
|
|
259
|
+
"reminds": {"title": "Reminds", "enum": list(policy.prohibitions)},
|
|
260
|
+
"entries": {"title": "Entries", "type": "array", "items": Entry.model_json_schema()},
|
|
261
|
+
},
|
|
262
|
+
"required": ["reminds", "entries"],
|
|
263
|
+
}
|
|
264
|
+
return {"profile": profile_schema(policy.structure), "lexicon": lexicon}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _classification(c: Classification) -> dict[str, Any]:
|
|
268
|
+
values = {k: sorted(v) if isinstance(v, frozenset) else v for k, v in c.values.items()}
|
|
269
|
+
return {**asdict(c), "values": values}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _json(value: Any) -> Any:
|
|
273
|
+
"""The policy's dataclasses as objects, sets and tuples as lists."""
|
|
274
|
+
return list(value) if isinstance(value, set | frozenset | tuple) else asdict(value)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _outline(policy: Policy) -> dict[str, Any]:
|
|
278
|
+
"""The policy as JSON: everything the page draws, from the document to the grids and the previews of every id."""
|
|
279
|
+
return {
|
|
280
|
+
"name": f"{policy.root.name} · {policy.name}",
|
|
281
|
+
"context": policy.context,
|
|
282
|
+
"principles": list(policy.principles.values()),
|
|
283
|
+
"turn_axes": list(policy.turn_axes.values()),
|
|
284
|
+
"response_axes": list(policy.response_axes.values()),
|
|
285
|
+
"tiers": [
|
|
286
|
+
{
|
|
287
|
+
"id": t.id,
|
|
288
|
+
"label": t.label,
|
|
289
|
+
"definition": t.definition,
|
|
290
|
+
"instruction": t.instruction,
|
|
291
|
+
"condition": t.condition,
|
|
292
|
+
"cells": {c.id: {"allowed": c.allowed, "examples": c.examples} for c in t.cells.values()},
|
|
293
|
+
}
|
|
294
|
+
for t in policy.tiers.values()
|
|
295
|
+
],
|
|
296
|
+
"prohibitions": list(policy.prohibitions.values()),
|
|
297
|
+
"lexicons": [{"name": x.name, "reminds": x.reminds, "size": len(x.entries)} for x in policy.lexicons],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _handler(workspace: Workspace) -> type[BaseHTTPRequestHandler]:
|
|
302
|
+
static = package_files("rulegrid_editor").joinpath("static")
|
|
303
|
+
|
|
304
|
+
class Handler(BaseHTTPRequestHandler):
|
|
305
|
+
def _send(self, status: int, body: bytes, kind: str = "application/json") -> None:
|
|
306
|
+
self.send_response(status)
|
|
307
|
+
self.send_header("Content-Type", kind)
|
|
308
|
+
self.send_header("Content-Length", str(len(body)))
|
|
309
|
+
self.send_header("Cache-Control", "no-cache")
|
|
310
|
+
self.end_headers()
|
|
311
|
+
self.wfile.write(body)
|
|
312
|
+
|
|
313
|
+
def do_GET(self) -> None:
|
|
314
|
+
if self.path == "/api/files":
|
|
315
|
+
body = {"files": workspace.files(), "classifier": workspace.classifier is not None}
|
|
316
|
+
return self._send(200, json.dumps(body).encode())
|
|
317
|
+
name = "index.html" if self.path == "/" else self.path.removeprefix("/static/")
|
|
318
|
+
file = static.joinpath(*name.split("/"))
|
|
319
|
+
if ".." in name or not file.is_file():
|
|
320
|
+
return self._send(404, b"{}")
|
|
321
|
+
kind = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
322
|
+
self._send(200, file.read_bytes(), f"{kind}; charset=utf-8")
|
|
323
|
+
|
|
324
|
+
def do_POST(self) -> None:
|
|
325
|
+
# A cross-origin form or no-CORS fetch cannot send application/json without a preflight.
|
|
326
|
+
if self.headers.get_content_type() != "application/json":
|
|
327
|
+
return self._send(415, b'{"error": "Expected application/json"}')
|
|
328
|
+
try:
|
|
329
|
+
request = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
|
330
|
+
texts = request.pop("files", {})
|
|
331
|
+
routes: dict[str, Callable[[], Any]] = {
|
|
332
|
+
"/api/view": lambda: workspace.view(texts),
|
|
333
|
+
"/api/save": lambda: workspace.save(texts),
|
|
334
|
+
"/api/edit": lambda: {"text": workspace.edit(texts, **request)},
|
|
335
|
+
"/api/try": lambda: workspace.attempt(texts, request),
|
|
336
|
+
}
|
|
337
|
+
if self.path not in routes:
|
|
338
|
+
return self._send(404, b"{}")
|
|
339
|
+
self._send(200, json.dumps(routes[self.path](), default=_json).encode())
|
|
340
|
+
except Exception as error: # the page shows any failure, such as a classifier's, next to what caused it
|
|
341
|
+
self._send(400, json.dumps({"error": f"{type(error).__name__}: {error}"}).encode())
|
|
342
|
+
|
|
343
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
return Handler
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def serve(path: str | Path, port: int = 8765, classifier: Callable[[Policy], Classifier] | None = None) -> None:
|
|
350
|
+
"""Serve the editor for the profile at `path` on `http://127.0.0.1:{port}`, until interrupted.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
path: a profile directory, such as `examples/rbr/profiles/default`, its `profile.yaml`, or a policy directory
|
|
354
|
+
with a single profile.
|
|
355
|
+
port: the local port.
|
|
356
|
+
classifier: makes the try panel's classifier from the policy being edited. Without it, the try panel reads
|
|
357
|
+
the values set by hand.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
PolicyError: when the profile does not load.
|
|
361
|
+
"""
|
|
362
|
+
with ThreadingHTTPServer(("127.0.0.1", port), _handler(Workspace(Path(path), classifier))) as server:
|
|
363
|
+
print(f"rulegrid editor on http://127.0.0.1:{server.server_port}", flush=True)
|
|
364
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
365
|
+
server.serve_forever()
|