lovspor 0.2.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.
- lovspor-0.2.0/.gitignore +43 -0
- lovspor-0.2.0/LICENSE +21 -0
- lovspor-0.2.0/PKG-INFO +146 -0
- lovspor-0.2.0/README.md +96 -0
- lovspor-0.2.0/docs/architecture.md +190 -0
- lovspor-0.2.0/docs/data-model.md +220 -0
- lovspor-0.2.0/docs/decisions.md +564 -0
- lovspor-0.2.0/docs/embeddings.md +178 -0
- lovspor-0.2.0/docs/legal-and-sources.md +54 -0
- lovspor-0.2.0/docs/mcp.md +774 -0
- lovspor-0.2.0/docs/operations.md +148 -0
- lovspor-0.2.0/docs/releasing.md +67 -0
- lovspor-0.2.0/docs/roadmap.md +336 -0
- lovspor-0.2.0/evals/README.md +41 -0
- lovspor-0.2.0/evals/__init__.py +1 -0
- lovspor-0.2.0/evals/fixtures/synthetic_corpus.yaml +544 -0
- lovspor-0.2.0/evals/personas.yaml +135 -0
- lovspor-0.2.0/evals/results/2026-04-29.md +168 -0
- lovspor-0.2.0/evals/results/2026-05-08-frida-opus-baseline.md +682 -0
- lovspor-0.2.0/evals/runner.py +1321 -0
- lovspor-0.2.0/evals/scenarios/anne.yaml +224 -0
- lovspor-0.2.0/evals/scenarios/erik.yaml +213 -0
- lovspor-0.2.0/evals/scenarios/frida.yaml +227 -0
- lovspor-0.2.0/evals/scenarios/ingrid.yaml +200 -0
- lovspor-0.2.0/evals/scenarios/kari.yaml +210 -0
- lovspor-0.2.0/evals/scenarios/lars.yaml +199 -0
- lovspor-0.2.0/evals/scenarios/magnus.yaml +203 -0
- lovspor-0.2.0/evals/scenarios/maria.yaml +203 -0
- lovspor-0.2.0/evals/scenarios/ola.yaml +206 -0
- lovspor-0.2.0/pyproject.toml +170 -0
- lovspor-0.2.0/src/lovspor/__init__.py +3 -0
- lovspor-0.2.0/src/lovspor/cli.py +167 -0
- lovspor-0.2.0/src/lovspor/corpus_fetch.py +143 -0
- lovspor-0.2.0/src/lovspor/embeddings/__init__.py +63 -0
- lovspor-0.2.0/src/lovspor/embeddings/model.py +328 -0
- lovspor-0.2.0/src/lovspor/embeddings/quantize.py +58 -0
- lovspor-0.2.0/src/lovspor/embeddings/search.py +215 -0
- lovspor-0.2.0/src/lovspor/embeddings/sections.py +99 -0
- lovspor-0.2.0/src/lovspor/embeddings/store.py +171 -0
- lovspor-0.2.0/src/lovspor/errors.py +55 -0
- lovspor-0.2.0/src/lovspor/extraction/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/extraction/tarball.py +95 -0
- lovspor-0.2.0/src/lovspor/history.py +380 -0
- lovspor-0.2.0/src/lovspor/mcp.py +2569 -0
- lovspor-0.2.0/src/lovspor/parsing/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/parsing/xml_normalizer.py +98 -0
- lovspor-0.2.0/src/lovspor/rendering/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/rendering/document.py +183 -0
- lovspor-0.2.0/src/lovspor/rendering/frontmatter.py +86 -0
- lovspor-0.2.0/src/lovspor/rendering/markdown_renderer.py +430 -0
- lovspor-0.2.0/src/lovspor/rendering/slug.py +124 -0
- lovspor-0.2.0/src/lovspor/retry.py +42 -0
- lovspor-0.2.0/src/lovspor/settings.py +181 -0
- lovspor-0.2.0/src/lovspor/sources/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/sources/lovdata.py +291 -0
- lovspor-0.2.0/src/lovspor/storage/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/storage/manifest.py +129 -0
- lovspor-0.2.0/src/lovspor/sync/__init__.py +0 -0
- lovspor-0.2.0/src/lovspor/sync/change_detector.py +65 -0
- lovspor-0.2.0/src/lovspor/sync/document_io.py +163 -0
- lovspor-0.2.0/src/lovspor/sync/git_commit.py +166 -0
- lovspor-0.2.0/src/lovspor/sync/orchestrator.py +1448 -0
- lovspor-0.2.0/src/lovspor/timetravel.py +194 -0
lovspor-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Virtual environment
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Tooling caches
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
.coverage.*
|
|
19
|
+
htmlcov/
|
|
20
|
+
.mutmut-cache
|
|
21
|
+
.mutmut-cache/
|
|
22
|
+
mutmut-stats.json
|
|
23
|
+
html/
|
|
24
|
+
.tox/
|
|
25
|
+
.uv-cache/
|
|
26
|
+
.hypothesis/
|
|
27
|
+
|
|
28
|
+
# Environment
|
|
29
|
+
.env
|
|
30
|
+
.envrc
|
|
31
|
+
|
|
32
|
+
# Local data — never commit (see CLAUDE.md and docs/legal-and-sources.md)
|
|
33
|
+
data/cache/
|
|
34
|
+
data/raw/
|
|
35
|
+
data/state/
|
|
36
|
+
*.tar.bz2
|
|
37
|
+
*.tar.gz
|
|
38
|
+
|
|
39
|
+
# Editor / OS
|
|
40
|
+
.DS_Store
|
|
41
|
+
.idea/
|
|
42
|
+
.vscode/
|
|
43
|
+
*.swp
|
lovspor-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bartosz Kobylinski
|
|
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.
|
lovspor-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lovspor
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Norwegian law change tracker — produces the lovverk corpus from Lovdata public data
|
|
5
|
+
Project-URL: Homepage, https://github.com/bartoszkobylinski/lovspor
|
|
6
|
+
Project-URL: Corpus, https://github.com/bartoszkobylinski/lovverk
|
|
7
|
+
Project-URL: Issues, https://github.com/bartoszkobylinski/lovspor/issues
|
|
8
|
+
Author-email: Bartosz Kobylinski <bartosz.kobylinski@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Bartosz Kobylinski
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: legal-corpus,lovdata,nlod,norwegian-law,rag
|
|
32
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
38
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
39
|
+
Requires-Python: >=3.12
|
|
40
|
+
Requires-Dist: httpx>=0.27.0
|
|
41
|
+
Requires-Dist: lxml>=5.3.0
|
|
42
|
+
Requires-Dist: mcp>=1.0.0
|
|
43
|
+
Requires-Dist: numpy>=1.26.0
|
|
44
|
+
Requires-Dist: pydantic>=2.9.0
|
|
45
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
46
|
+
Requires-Dist: pyyaml>=6.0
|
|
47
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
48
|
+
Requires-Dist: typer>=0.12.5
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# lovspor
|
|
52
|
+
|
|
53
|
+
Norwegian law change tracker. Engine that produces the [`lovverk`](https://github.com/bartoszkobylinski/lovverk) corpus from Lovdata's public-data API and serves it to AI assistants over MCP (Model Context Protocol).
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
|
|
57
|
+
**Production.** A scheduled GitHub Actions workflow runs daily at 04:00 UTC, pulls the latest tarballs from Lovdata, classifies each document as new / updated / renamed / removed, renders the changes to Markdown, and pushes the diff to `lovverk` as conventional-commit history. The corpus mirrors close to **6 000 acts** (Norwegian *lover* and central *forskrifter*), each with a structured per-act change history under `<dataset>/history/<slug>.json`. The exact live count is always available from the MCP `corpus_status` tool.
|
|
58
|
+
|
|
59
|
+
Sprint 9 (MERGED 2026-05-06) added per-section embeddings to the corpus and a four-layer anti-hallucination story to the MCP surface: `semantic_search` (cosine over embeddings), `verify_quote` (verbatim-citation guard), validated `cross_references` on `get_section`, and `validate_citation` as the off-ramp for ambiguous citations.
|
|
60
|
+
|
|
61
|
+
See [`docs/decisions.md`](docs/decisions.md) for the full architecture and design rationale.
|
|
62
|
+
|
|
63
|
+
## MCP server
|
|
64
|
+
|
|
65
|
+
`lovspor` ships a stdio MCP server that exposes the `lovverk` corpus to AI assistants — Claude Desktop, Claude Code, or any MCP client. Once configured, you can ask *"what changed in Skatteloven this year?"* or *"are there forskrifter about jernbane?"* and the assistant answers from the live corpus instead of stale training data.
|
|
66
|
+
|
|
67
|
+
**Setup — three steps:**
|
|
68
|
+
|
|
69
|
+
1. **Install [`uv`](https://docs.astral.sh/uv/)** (it provides `uvx`, which runs `lovspor` on demand — no manual install).
|
|
70
|
+
|
|
71
|
+
2. **Fetch the corpus.** One command shallow-clones the legal text to the default cache (`~/.cache/lovverk`):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uvx --from "git+https://github.com/bartoszkobylinski/lovspor.git" lovspor fetch-corpus
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Re-run it any time to update — it reports `cloned`, `updated`, or `unchanged`.
|
|
78
|
+
|
|
79
|
+
3. **Register the server.** `lovspor mcp` finds that cache automatically. With Claude Code:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
claude mcp add lovverk -- \
|
|
83
|
+
uvx --from "git+https://github.com/bartoszkobylinski/lovspor.git" lovspor mcp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or add it to your client config directly — Claude Desktop's `claude_desktop_config.json`, or `~/.claude.json` for Claude Code:
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"lovverk": {
|
|
92
|
+
"command": "uvx",
|
|
93
|
+
"args": [
|
|
94
|
+
"--from", "git+https://github.com/bartoszkobylinski/lovspor.git",
|
|
95
|
+
"lovspor", "mcp"
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Restart the client and `lovverk` appears in its MCP list. Fifteen of the sixteen tools work immediately — no key, and no network access beyond your local corpus clone.
|
|
103
|
+
|
|
104
|
+
**Optional — enable `semantic_search`.** The one search-by-meaning tool needs an OpenAI API key: it embeds *your query* at call time (the corpus vectors ship pre-computed, so you never re-embed the corpus yourself). Bring your own key via the server's `env`:
|
|
105
|
+
|
|
106
|
+
```jsonc
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"lovverk": {
|
|
110
|
+
"command": "uvx",
|
|
111
|
+
"args": [
|
|
112
|
+
"--from", "git+https://github.com/bartoszkobylinski/lovspor.git",
|
|
113
|
+
"lovspor", "mcp"
|
|
114
|
+
],
|
|
115
|
+
"env": { "OPENAI_API_KEY": "sk-...your-own-key..." }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It's your key in your own local config file — keep that file private and never commit it. Without a key, `semantic_search` is simply disabled; the other fifteen tools are unaffected.
|
|
122
|
+
|
|
123
|
+
Keep the corpus fresh by re-running `lovspor fetch-corpus` (the engine re-syncs daily at 04:00 UTC); the `corpus_status` tool tells the assistant when your clone has drifted.
|
|
124
|
+
|
|
125
|
+
> **Coming soon:** once `lovspor` is published to PyPI, the `--from git+https://…` prefix drops — both commands collapse to plain `uvx lovspor fetch-corpus` and `uvx lovspor mcp …`. Tracked in [`docs/roadmap.md`](docs/roadmap.md).
|
|
126
|
+
|
|
127
|
+
See [`docs/mcp.md`](docs/mcp.md) for the full setup guide, all sixteen tools documented with examples (`get_law`, `get_law_at`, `list_law_versions`, `diff_law_versions`, `get_section`, `list_sections`, `get_law_history`, `list_recent_changes`, `search_laws`, `search_body`, `semantic_search`, `validate_citation`, `verify_quote`, `get_eu_basis`, `search_eu_implementations`, `corpus_status`), troubleshooting, and limitations. The binary embedding format that powers `semantic_search` is documented in [`docs/embeddings.md`](docs/embeddings.md).
|
|
128
|
+
|
|
129
|
+
Persona-driven offline evals for the MCP tool surface live in [`evals/`](evals/). Run them with `uv run lovspor-eval`.
|
|
130
|
+
|
|
131
|
+
## Sources
|
|
132
|
+
|
|
133
|
+
- `https://api.lovdata.no/v1/publicData/get/gjeldende-lover.tar.bz2` — current Norwegian laws
|
|
134
|
+
- `https://api.lovdata.no/v1/publicData/get/gjeldende-sentrale-forskrifter.tar.bz2` — current central regulations
|
|
135
|
+
|
|
136
|
+
Data is licensed under [Norsk lisens for offentlige data (NLOD) 2.0](https://data.norge.no/nlod/no/2.0/).
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
The engine code in this repository is licensed under MIT. See [LICENSE](LICENSE).
|
|
141
|
+
|
|
142
|
+
The legal text produced by this engine is published in the [`lovverk`](https://github.com/bartoszkobylinski/lovverk) repository under NLOD 2.0, with attribution to Lovdata.
|
|
143
|
+
|
|
144
|
+
## Related work
|
|
145
|
+
|
|
146
|
+
- [`cloveras/lovdata2`](https://github.com/cloveras/lovdata2) — JSON tooling and MCP server for the same Lovdata public data. `lovspor` is complementary, focused on Markdown rendering, Git-based change tracking, and an MCP server scoped to the `lovverk` corpus shape.
|
lovspor-0.2.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# lovspor
|
|
2
|
+
|
|
3
|
+
Norwegian law change tracker. Engine that produces the [`lovverk`](https://github.com/bartoszkobylinski/lovverk) corpus from Lovdata's public-data API and serves it to AI assistants over MCP (Model Context Protocol).
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
**Production.** A scheduled GitHub Actions workflow runs daily at 04:00 UTC, pulls the latest tarballs from Lovdata, classifies each document as new / updated / renamed / removed, renders the changes to Markdown, and pushes the diff to `lovverk` as conventional-commit history. The corpus mirrors close to **6 000 acts** (Norwegian *lover* and central *forskrifter*), each with a structured per-act change history under `<dataset>/history/<slug>.json`. The exact live count is always available from the MCP `corpus_status` tool.
|
|
8
|
+
|
|
9
|
+
Sprint 9 (MERGED 2026-05-06) added per-section embeddings to the corpus and a four-layer anti-hallucination story to the MCP surface: `semantic_search` (cosine over embeddings), `verify_quote` (verbatim-citation guard), validated `cross_references` on `get_section`, and `validate_citation` as the off-ramp for ambiguous citations.
|
|
10
|
+
|
|
11
|
+
See [`docs/decisions.md`](docs/decisions.md) for the full architecture and design rationale.
|
|
12
|
+
|
|
13
|
+
## MCP server
|
|
14
|
+
|
|
15
|
+
`lovspor` ships a stdio MCP server that exposes the `lovverk` corpus to AI assistants — Claude Desktop, Claude Code, or any MCP client. Once configured, you can ask *"what changed in Skatteloven this year?"* or *"are there forskrifter about jernbane?"* and the assistant answers from the live corpus instead of stale training data.
|
|
16
|
+
|
|
17
|
+
**Setup — three steps:**
|
|
18
|
+
|
|
19
|
+
1. **Install [`uv`](https://docs.astral.sh/uv/)** (it provides `uvx`, which runs `lovspor` on demand — no manual install).
|
|
20
|
+
|
|
21
|
+
2. **Fetch the corpus.** One command shallow-clones the legal text to the default cache (`~/.cache/lovverk`):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx --from "git+https://github.com/bartoszkobylinski/lovspor.git" lovspor fetch-corpus
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Re-run it any time to update — it reports `cloned`, `updated`, or `unchanged`.
|
|
28
|
+
|
|
29
|
+
3. **Register the server.** `lovspor mcp` finds that cache automatically. With Claude Code:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude mcp add lovverk -- \
|
|
33
|
+
uvx --from "git+https://github.com/bartoszkobylinski/lovspor.git" lovspor mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or add it to your client config directly — Claude Desktop's `claude_desktop_config.json`, or `~/.claude.json` for Claude Code:
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"lovverk": {
|
|
42
|
+
"command": "uvx",
|
|
43
|
+
"args": [
|
|
44
|
+
"--from", "git+https://github.com/bartoszkobylinski/lovspor.git",
|
|
45
|
+
"lovspor", "mcp"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Restart the client and `lovverk` appears in its MCP list. Fifteen of the sixteen tools work immediately — no key, and no network access beyond your local corpus clone.
|
|
53
|
+
|
|
54
|
+
**Optional — enable `semantic_search`.** The one search-by-meaning tool needs an OpenAI API key: it embeds *your query* at call time (the corpus vectors ship pre-computed, so you never re-embed the corpus yourself). Bring your own key via the server's `env`:
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"lovverk": {
|
|
60
|
+
"command": "uvx",
|
|
61
|
+
"args": [
|
|
62
|
+
"--from", "git+https://github.com/bartoszkobylinski/lovspor.git",
|
|
63
|
+
"lovspor", "mcp"
|
|
64
|
+
],
|
|
65
|
+
"env": { "OPENAI_API_KEY": "sk-...your-own-key..." }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It's your key in your own local config file — keep that file private and never commit it. Without a key, `semantic_search` is simply disabled; the other fifteen tools are unaffected.
|
|
72
|
+
|
|
73
|
+
Keep the corpus fresh by re-running `lovspor fetch-corpus` (the engine re-syncs daily at 04:00 UTC); the `corpus_status` tool tells the assistant when your clone has drifted.
|
|
74
|
+
|
|
75
|
+
> **Coming soon:** once `lovspor` is published to PyPI, the `--from git+https://…` prefix drops — both commands collapse to plain `uvx lovspor fetch-corpus` and `uvx lovspor mcp …`. Tracked in [`docs/roadmap.md`](docs/roadmap.md).
|
|
76
|
+
|
|
77
|
+
See [`docs/mcp.md`](docs/mcp.md) for the full setup guide, all sixteen tools documented with examples (`get_law`, `get_law_at`, `list_law_versions`, `diff_law_versions`, `get_section`, `list_sections`, `get_law_history`, `list_recent_changes`, `search_laws`, `search_body`, `semantic_search`, `validate_citation`, `verify_quote`, `get_eu_basis`, `search_eu_implementations`, `corpus_status`), troubleshooting, and limitations. The binary embedding format that powers `semantic_search` is documented in [`docs/embeddings.md`](docs/embeddings.md).
|
|
78
|
+
|
|
79
|
+
Persona-driven offline evals for the MCP tool surface live in [`evals/`](evals/). Run them with `uv run lovspor-eval`.
|
|
80
|
+
|
|
81
|
+
## Sources
|
|
82
|
+
|
|
83
|
+
- `https://api.lovdata.no/v1/publicData/get/gjeldende-lover.tar.bz2` — current Norwegian laws
|
|
84
|
+
- `https://api.lovdata.no/v1/publicData/get/gjeldende-sentrale-forskrifter.tar.bz2` — current central regulations
|
|
85
|
+
|
|
86
|
+
Data is licensed under [Norsk lisens for offentlige data (NLOD) 2.0](https://data.norge.no/nlod/no/2.0/).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
The engine code in this repository is licensed under MIT. See [LICENSE](LICENSE).
|
|
91
|
+
|
|
92
|
+
The legal text produced by this engine is published in the [`lovverk`](https://github.com/bartoszkobylinski/lovverk) repository under NLOD 2.0, with attribution to Lovdata.
|
|
93
|
+
|
|
94
|
+
## Related work
|
|
95
|
+
|
|
96
|
+
- [`cloveras/lovdata2`](https://github.com/cloveras/lovdata2) — JSON tooling and MCP server for the same Lovdata public data. `lovspor` is complementary, focused on Markdown rendering, Git-based change tracking, and an MCP server scoped to the `lovverk` corpus shape.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`lovspor` is the **engine**. It downloads Lovdata public-data tarballs, extracts
|
|
4
|
+
and normalizes XML, hashes the normalized XML to detect changes, deterministically
|
|
5
|
+
renders Markdown, and commits only the changed laws into the sibling
|
|
6
|
+
[`lovverk`](https://github.com/bartoszkobylinski/lovverk) corpus repo. It also
|
|
7
|
+
ships a read-only **MCP server** that serves that corpus to AI clients.
|
|
8
|
+
|
|
9
|
+
Legal text never lives in this repo — only the code that produces it. See
|
|
10
|
+
[`decisions.md`](decisions.md) for the *why* behind each choice; this document is
|
|
11
|
+
the *how the code is organized and flows* view.
|
|
12
|
+
|
|
13
|
+
## High-level flow
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Lovdata public API
|
|
17
|
+
│ GET /v1/publicData/list, /get/{filename}
|
|
18
|
+
▼
|
|
19
|
+
download tar.bz2 ──► data/cache/ (gitignored)
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
extract XML members (in-memory, never extractall)
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
SHA-256 on normalized (C14N) XML ◄── the change-detection key
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
diff against manifest.json (prior state)
|
|
29
|
+
│
|
|
30
|
+
▼
|
|
31
|
+
render changed docs → Markdown (+ optional OpenAI embeddings sidecar)
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
write to the lovverk clone (sibling working tree)
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
git commit per changed document (+ a final manifest/index/history commit)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The rationale-level version of this diagram, including commit modes, lives in
|
|
41
|
+
[`decisions.md` §5](decisions.md).
|
|
42
|
+
|
|
43
|
+
## Module map
|
|
44
|
+
|
|
45
|
+
Grouped by subpackage under `src/lovspor/`. Subpackage `__init__.py` files are
|
|
46
|
+
empty package markers except `embeddings/__init__.py`, which is a re-export
|
|
47
|
+
facade.
|
|
48
|
+
|
|
49
|
+
### Top level — `src/lovspor/`
|
|
50
|
+
|
|
51
|
+
| Module | Responsibility | Key public API |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `cli.py` | Typer CLI entry point; loads `.env` in the group callback before Typer resolves `envvar=` options. | `app`; commands `info`, `seed`, `sync`, `mcp` |
|
|
54
|
+
| `settings.py` | Runtime config resolved from env / `.env`; frozen Pydantic model; paths resolved absolute. | `Settings.from_env()`, `load_env()` |
|
|
55
|
+
| `errors.py` | Exception hierarchy so callers never catch bare `Exception`. | `LovsporError` + `NetworkError`, `ParseError`, `RenderError`, `ExtractionError`, `ConfigError`, `CorpusStateError`, `MassRemovalError` |
|
|
56
|
+
| `retry.py` | Dependency-free exponential-backoff retry helper. | `retry_with_backoff(...)` |
|
|
57
|
+
| `history.py` | Per-act change history from `git log --follow --numstat`; writes `history/<slug>.{json,md}`. | `extract_history()`, `write_history()`, `render_history_markdown()` |
|
|
58
|
+
| `timetravel.py` | Time-machine: a doc's text as of a past date via `git log --follow` + `git show <sha>:<path>`. | `get_law_at_revision(...)`, `resolve_law_at_revision(...)` |
|
|
59
|
+
| `mcp.py` | Stdio MCP server exposing 16 read-only tools over a local `lovverk` clone. | `serve()`, `build_server()`, `CorpusReader` |
|
|
60
|
+
|
|
61
|
+
### `sources/` — Lovdata API boundary
|
|
62
|
+
|
|
63
|
+
| Module | Responsibility | Key public API |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `sources/lovdata.py` | HTTP client for `api.lovdata.no/v1/publicData`: list catalogue, stream-download archives with sha256 + size verify, atomic `.part` rename, retry on 429/5xx. | `LovdataClient`, `LovdataArchive`, `DownloadResult` |
|
|
66
|
+
|
|
67
|
+
### `extraction/` — tar safety
|
|
68
|
+
|
|
69
|
+
| Module | Responsibility | Key public API |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `extraction/tarball.py` | Safe in-memory iteration of XML members from a `.tar.bz2`. Never `extractall`/`extract`; validates member names. | `iter_tarball_xml()`, `TarballMember` |
|
|
72
|
+
|
|
73
|
+
### `parsing/` — normalization + hashing
|
|
74
|
+
|
|
75
|
+
| Module | Responsibility | Key public API |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `parsing/xml_normalizer.py` | Hardened lxml parser (XXE / billion-laughs safe) + W3C C14N canonicalization + SHA-256 change-detection hash. | `safe_parser()`, `canonicalize_xml()`, `hash_normalized_xml()` |
|
|
78
|
+
|
|
79
|
+
### `rendering/` — XML → Markdown (deterministic)
|
|
80
|
+
|
|
81
|
+
| Module | Responsibility | Key public API |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `rendering/markdown_renderer.py` | Deterministic Lovdata-HTML → Markdown body; escapes Markdown specials; raises `RenderError` on dropped text. | `render_markdown()` |
|
|
84
|
+
| `rendering/document.py` | Extract doc metadata from `<header><dl>`; build the full front-matter model incl. EU basis (CELEX). | `build_frontmatter()`, `extract_xml_metadata()`, `LegalDocumentFrontMatter` |
|
|
85
|
+
| `rendering/frontmatter.py` | Deterministic minimal YAML serializer (no PyYAML). | `serialize_frontmatter()` |
|
|
86
|
+
| `rendering/slug.py` | Human-readable filename slug (short_title→title→doc_id), UTF-8 byte cap, deterministic collision resolution. | `derive_slug()`, `resolve_collisions()` |
|
|
87
|
+
|
|
88
|
+
### `storage/` — manifest ledger
|
|
89
|
+
|
|
90
|
+
| Module | Responsibility | Key public API |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `storage/manifest.py` | The change-detection ledger; deterministic sorted-JSON read/write; versioned schema. | `read_manifest()`, `write_manifest()`, `Manifest`, `ManifestRecord` |
|
|
93
|
+
|
|
94
|
+
### `sync/` — orchestration + IO + git
|
|
95
|
+
|
|
96
|
+
| Module | Responsibility | Key public API |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `sync/orchestrator.py` | **The pipeline.** `run_sync()` composes download→extract→hash→diff→render→embed→commit; handles renames, tombstones, the mass-removal guard, and one-time migrations. | `run_sync()`, `SyncReport` |
|
|
99
|
+
| `sync/change_detector.py` | Pure classifier: upstream hashes vs prior manifest → new/changed/removed/unchanged. | `detect_changes()`, `ChangeSet` |
|
|
100
|
+
| `sync/document_io.py` | Compose renderer + front matter + filesystem IO; dataset↔subdir mapping; write/delete docs; generate `INDEX.md`. | `render_full_document()`, `write_document()`, `generate_index()` |
|
|
101
|
+
| `sync/git_commit.py` | Thin `git` CLI wrappers (list args, never `shell=True`). | `add()`, `commit()`, `has_staged_changes()` |
|
|
102
|
+
|
|
103
|
+
### `embeddings/` — semantic-search vectors
|
|
104
|
+
|
|
105
|
+
| Module | Responsibility | Key public API |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `embeddings/model.py` | OpenAI `text-embedding-3-large` client (sync httpx), tiktoken-aware truncation, batching + retry, L2-normalized output; token chunker. | `OpenAIEmbedder`, `EmbeddingModel` (Protocol), `split_to_token_chunks()` |
|
|
108
|
+
| `embeddings/sections.py` | Split rendered Markdown into `### § N-M.` embedding units. | `iter_sections()`, `EmbeddingSection` |
|
|
109
|
+
| `embeddings/quantize.py` | float32 ↔ int8 linear quantization with per-batch scale. | `quantize_int8()`, `dequantize_int8()` |
|
|
110
|
+
| `embeddings/store.py` | Binary `<slug>.bin` (LSPE) format read/write; atomic temp-rename. | `write_embeddings()`, `read_embeddings()`, `EmbeddingFile` |
|
|
111
|
+
| `embeddings/search.py` | In-memory int8 top-K cosine index; chunked matmul; deterministic tie-break; dedup by `(slug, section_id)`. | `EmbeddingIndex`, `SearchHit` |
|
|
112
|
+
|
|
113
|
+
The binary embedding format is documented in [`embeddings.md`](embeddings.md); the
|
|
114
|
+
data models these modules exchange are in [`data-model.md`](data-model.md).
|
|
115
|
+
|
|
116
|
+
## The sync pipeline
|
|
117
|
+
|
|
118
|
+
There is **one orchestrator**: `run_sync(settings)` in `sync/orchestrator.py`.
|
|
119
|
+
Both the `seed` and `sync` CLI commands call it — they are the same pipeline; a
|
|
120
|
+
missing manifest just makes the change detector classify everything as new.
|
|
121
|
+
|
|
122
|
+
In order:
|
|
123
|
+
|
|
124
|
+
1. **CLI entry** (`cli.py`) — the group callback runs `load_env()` first, then the
|
|
125
|
+
command builds `Settings.from_env()` and calls `run_sync()`.
|
|
126
|
+
2. **Preconditions** — the corpus must be a git repo; a **dirty worktree aborts**
|
|
127
|
+
with `CorpusStateError` (crash residue would otherwise be misread as "nothing
|
|
128
|
+
changed"). The prior `manifest.json` is read here.
|
|
129
|
+
3. **One-time migrations** — Sprint 5 history backfill, Sprint 8 `eu_basis`
|
|
130
|
+
re-render, and Sprint 9 embeddings backfill (the last only if an OpenAI key is
|
|
131
|
+
set). Each fires once and emits its own commit(s) before regular work.
|
|
132
|
+
4. **Collect upstream** — download each tracked dataset tarball
|
|
133
|
+
(`gjeldende-lover`, `gjeldende-sentrale-forskrifter`) into
|
|
134
|
+
`data/cache/archives/`, iterate XML members, extract metadata, derive the base
|
|
135
|
+
slug, and **compute `hash_normalized_xml(member)`**. Slug collisions are
|
|
136
|
+
resolved **per dataset**.
|
|
137
|
+
5. **Change detection** — `detect_changes(upstream_hashes, prior)` returns disjoint
|
|
138
|
+
sorted `new/changed/removed/unchanged`; renames are then identified among
|
|
139
|
+
unchanged-content docs whose slug/path moved. **No-op fast path:** if nothing is
|
|
140
|
+
new/changed/removed/renamed, return early *without* rewriting the manifest or
|
|
141
|
+
committing — the "nothing to do → zero commits" contract.
|
|
142
|
+
6. **Mass-removal guard** — abort with `MassRemovalError` if any single dataset
|
|
143
|
+
(≥ 20 current docs) would lose more than `max_removal_ratio` (default 10%).
|
|
144
|
+
7. **Carry forward** — unchanged records are copied verbatim (preserving
|
|
145
|
+
`last_seen` so the manifest stays byte-identical); tombstones (`status="removed"`)
|
|
146
|
+
are carried so removals stay permanent.
|
|
147
|
+
8. **Render + embed** — for each new/changed/renamed doc, write the `.md`
|
|
148
|
+
(front matter + body) and, when an embedder is available, the
|
|
149
|
+
`<dataset>/embeddings/<slug>.bin` sidecar. A two-phase write-all-then-delete
|
|
150
|
+
sequence prevents path-cascade corruption when slugs shuffle between docs.
|
|
151
|
+
9. **Commit** — mode-aware (`git_commit_mode`, default `per-document`): one commit
|
|
152
|
+
per add/update/rename/remove, then history is extracted (`git log --follow` needs
|
|
153
|
+
those commits to exist), then one final commit bundling manifest + `INDEX.md` +
|
|
154
|
+
history. The `single` mode and migration paths use one bulk commit plus a history
|
|
155
|
+
follow-up (history needs the docs commit first).
|
|
156
|
+
10. **Return** a `SyncReport` of new/changed/removed/unchanged counts.
|
|
157
|
+
|
|
158
|
+
## Key invariants (enforced in code)
|
|
159
|
+
|
|
160
|
+
| Invariant | Where | How |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| **Deterministic rendering** (same XML → byte-identical Markdown) | `rendering/markdown_renderer.py`, `rendering/frontmatter.py` | No wall-clock in body; custom deterministic YAML serializer; front-matter key order = model field order. Manifest and `.bin` writes are likewise byte-deterministic. |
|
|
163
|
+
| **Hash on normalized XML** (never on Markdown/HTML) | `parsing/xml_normalizer.py:hash_normalized_xml` | SHA-256 of C14N-canonicalized XML, called only on raw XML bytes. A documented gap: `remove_blank_text=True` makes inter-element whitespace hash-invisible (accepted — [`decisions.md` §14](decisions.md)). |
|
|
164
|
+
| **Safe XML parser** (no XXE, billion-laughs, network, or unbounded tree) | `parsing/xml_normalizer.py:safe_parser` | `resolve_entities=False, no_network=True, huge_tree=False, remove_comments=True`. Unresolved custom entities raise `ParseError`. |
|
|
165
|
+
| **Tar safety / CVE-2007-4559** | `extraction/tarball.py` | Never `extractall`/`extract`; reads members into memory via `extractfile()`; rejects null bytes, absolute names, and `..` components. |
|
|
166
|
+
| **Mass-removal guard** | `sync/orchestrator.py:_guard_mass_removal` | Per-dataset abort if removed/total exceeds `max_removal_ratio`; guards against a truncated/empty upstream tarball wiping the corpus. |
|
|
167
|
+
| **Tombstones** (removals are permanent) | `sync/orchestrator.py` (`_tombstone`, `_carry_tombstones`) | Removed docs keep their record with `status="removed"`, carried forward every sync. |
|
|
168
|
+
| **No shell injection** | `sync/git_commit.py`, `history.py`, `timetravel.py` | Every `subprocess.run` uses list args, never `shell=True`. |
|
|
169
|
+
|
|
170
|
+
## External boundaries
|
|
171
|
+
|
|
172
|
+
- **Lovdata public-data API** — reached only from `sources/lovdata.py`
|
|
173
|
+
(`GET /list`, `GET /get/{filename}`). The only outbound HTTP to Lovdata; no HTML
|
|
174
|
+
scraping anywhere.
|
|
175
|
+
- **OpenAI API** — reached only from `embeddings/model.py`
|
|
176
|
+
(`text-embedding-3-large`, 3072-dim). On the engine side it is optional (no key →
|
|
177
|
+
embeddings skipped, Markdown still produced). On the MCP side it powers only
|
|
178
|
+
`semantic_search`; see the privacy note in [`mcp.md`](mcp.md).
|
|
179
|
+
- **git / the `lovverk` corpus** — `sync/git_commit.py` (write path), plus
|
|
180
|
+
`history.py`, `timetravel.py`, and `mcp.py` (read paths) shell out to `git`
|
|
181
|
+
against the local clone. Nothing in the engine code pushes to GitHub — the
|
|
182
|
+
scheduled workflow does that.
|
|
183
|
+
|
|
184
|
+
## MCP server
|
|
185
|
+
|
|
186
|
+
`lovspor mcp` builds a `FastMCP("lovverk")` server (`mcp.py`) transported over
|
|
187
|
+
**stdio** with 16 read-only tools. A `CorpusReader` reads `manifest.json` plus the
|
|
188
|
+
Markdown / `.bin` files from the local clone, caching in memory and dropping caches
|
|
189
|
+
when `manifest.json`'s mtime changes so a `git pull` under a long-lived server is
|
|
190
|
+
picked up. Full tool reference, setup, and limitations are in [`mcp.md`](mcp.md).
|