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.
Files changed (63) hide show
  1. lovspor-0.2.0/.gitignore +43 -0
  2. lovspor-0.2.0/LICENSE +21 -0
  3. lovspor-0.2.0/PKG-INFO +146 -0
  4. lovspor-0.2.0/README.md +96 -0
  5. lovspor-0.2.0/docs/architecture.md +190 -0
  6. lovspor-0.2.0/docs/data-model.md +220 -0
  7. lovspor-0.2.0/docs/decisions.md +564 -0
  8. lovspor-0.2.0/docs/embeddings.md +178 -0
  9. lovspor-0.2.0/docs/legal-and-sources.md +54 -0
  10. lovspor-0.2.0/docs/mcp.md +774 -0
  11. lovspor-0.2.0/docs/operations.md +148 -0
  12. lovspor-0.2.0/docs/releasing.md +67 -0
  13. lovspor-0.2.0/docs/roadmap.md +336 -0
  14. lovspor-0.2.0/evals/README.md +41 -0
  15. lovspor-0.2.0/evals/__init__.py +1 -0
  16. lovspor-0.2.0/evals/fixtures/synthetic_corpus.yaml +544 -0
  17. lovspor-0.2.0/evals/personas.yaml +135 -0
  18. lovspor-0.2.0/evals/results/2026-04-29.md +168 -0
  19. lovspor-0.2.0/evals/results/2026-05-08-frida-opus-baseline.md +682 -0
  20. lovspor-0.2.0/evals/runner.py +1321 -0
  21. lovspor-0.2.0/evals/scenarios/anne.yaml +224 -0
  22. lovspor-0.2.0/evals/scenarios/erik.yaml +213 -0
  23. lovspor-0.2.0/evals/scenarios/frida.yaml +227 -0
  24. lovspor-0.2.0/evals/scenarios/ingrid.yaml +200 -0
  25. lovspor-0.2.0/evals/scenarios/kari.yaml +210 -0
  26. lovspor-0.2.0/evals/scenarios/lars.yaml +199 -0
  27. lovspor-0.2.0/evals/scenarios/magnus.yaml +203 -0
  28. lovspor-0.2.0/evals/scenarios/maria.yaml +203 -0
  29. lovspor-0.2.0/evals/scenarios/ola.yaml +206 -0
  30. lovspor-0.2.0/pyproject.toml +170 -0
  31. lovspor-0.2.0/src/lovspor/__init__.py +3 -0
  32. lovspor-0.2.0/src/lovspor/cli.py +167 -0
  33. lovspor-0.2.0/src/lovspor/corpus_fetch.py +143 -0
  34. lovspor-0.2.0/src/lovspor/embeddings/__init__.py +63 -0
  35. lovspor-0.2.0/src/lovspor/embeddings/model.py +328 -0
  36. lovspor-0.2.0/src/lovspor/embeddings/quantize.py +58 -0
  37. lovspor-0.2.0/src/lovspor/embeddings/search.py +215 -0
  38. lovspor-0.2.0/src/lovspor/embeddings/sections.py +99 -0
  39. lovspor-0.2.0/src/lovspor/embeddings/store.py +171 -0
  40. lovspor-0.2.0/src/lovspor/errors.py +55 -0
  41. lovspor-0.2.0/src/lovspor/extraction/__init__.py +0 -0
  42. lovspor-0.2.0/src/lovspor/extraction/tarball.py +95 -0
  43. lovspor-0.2.0/src/lovspor/history.py +380 -0
  44. lovspor-0.2.0/src/lovspor/mcp.py +2569 -0
  45. lovspor-0.2.0/src/lovspor/parsing/__init__.py +0 -0
  46. lovspor-0.2.0/src/lovspor/parsing/xml_normalizer.py +98 -0
  47. lovspor-0.2.0/src/lovspor/rendering/__init__.py +0 -0
  48. lovspor-0.2.0/src/lovspor/rendering/document.py +183 -0
  49. lovspor-0.2.0/src/lovspor/rendering/frontmatter.py +86 -0
  50. lovspor-0.2.0/src/lovspor/rendering/markdown_renderer.py +430 -0
  51. lovspor-0.2.0/src/lovspor/rendering/slug.py +124 -0
  52. lovspor-0.2.0/src/lovspor/retry.py +42 -0
  53. lovspor-0.2.0/src/lovspor/settings.py +181 -0
  54. lovspor-0.2.0/src/lovspor/sources/__init__.py +0 -0
  55. lovspor-0.2.0/src/lovspor/sources/lovdata.py +291 -0
  56. lovspor-0.2.0/src/lovspor/storage/__init__.py +0 -0
  57. lovspor-0.2.0/src/lovspor/storage/manifest.py +129 -0
  58. lovspor-0.2.0/src/lovspor/sync/__init__.py +0 -0
  59. lovspor-0.2.0/src/lovspor/sync/change_detector.py +65 -0
  60. lovspor-0.2.0/src/lovspor/sync/document_io.py +163 -0
  61. lovspor-0.2.0/src/lovspor/sync/git_commit.py +166 -0
  62. lovspor-0.2.0/src/lovspor/sync/orchestrator.py +1448 -0
  63. lovspor-0.2.0/src/lovspor/timetravel.py +194 -0
@@ -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.
@@ -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).