feat(tools): map kv library variants to kb ids

This commit is contained in:
2026-09-17 00:06:36 +02:00
parent d9e7c5ebda
commit 7162726b66
5 changed files with 4114 additions and 16 deletions
+21 -12
View File
@@ -5,14 +5,14 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
## Current focus
D24 ist validiert (beide 500-Euro-Fragen verifiziert beantwortet; think-Fix
aktiv; Offline-Eval Recall@8 0,95; 95 Unit-Tests). M4-Richtung mit User
geklärt (D25-Vorentscheidung): Odoo orchestriert und rechnet (System of
Record), der Agent konsumiert nur das vorgegebene Ergebnis und prüft
Plausibilität gegen die KB. Die Aufweichung der Privacy-Regel 8 (keine
Lohndaten im Prompt) passiert bewusst erst im Rahmen der Odoo-Implementierung;
der Test-Agent bleibt `knowledge_base_only`. Odoo-Modul-Bestand wird vom User
verlinkt; danach D25/M4-Planung.
D24 validiert (500-Euro-Fragen verifiziert, think-Fix, Offline-Eval Recall@8
0,95). D25-Planung steht (planung.md Abschnitt 14): Odoo orchestriert und
rechnet (System of Record), Agent prüft Plausibilität, kein Rückpfad;
Privacy-Regel 8 wird erst per Feature-Flag `PV_REVIEW_MODE` im M4 aufgeweicht.
Modul-Review abgeschlossen (`.oddo-module/`), KV-Varianten-Mapping gebaut
(`tools/catalogs/kv_variant_map.json`, 439/614 abgedeckt, Seeds SI-2203/
SI-2748 getestet). Als Nächstes: D25-Umsetzung — Agent-`review`-Modus (M4.2)
und Odoo-Modul `l10n_at_payroll_agent` (M4.1).
## Completed (2026-09-16, Modul-Review/D25-Planung)
@@ -27,9 +27,9 @@ verlinkt; danach D25/M4-Planung.
×Jahr, DAG), lohnsteuer.py (§ 66/67/68 mit YTD-Freibetragsverbrauch),
payslip_private (KommSt/DZ/FLAF-DB/SZ), sachbezuege/reisekosten. Parameter
aus ÖGK-TASY-Export gegen offiziellen Report gespiegelt; 2027-Rahmen.
KV-Katalog `l10n.at.payroll.kv` mit versionierten `kv.wert`, Gruppen/Stufen,
CSV-Import-Wizard (Sprungwarnung >10 %) und `library_variant_id` auf die
KV-Library (SI-2203/SI-2748). D25-Planung steht in `planung.md`
KV-Katalog `l10n.at.payroll.kv` mit versionierten `kv.wert`, Gruppen/Stufen
und CSV-Import-Wizard (Sprungwarnung >10 %); `library_variant_id` verlinkt
auf die KV-Library (SI-2203/SI-2748). D25-Planung steht in `planung.md`
Abschnitt 14: neues Modul `l10n_at_payroll_agent`, ir.config_parameter plus
eigene Gruppe, serverseitiger Client, hart kodierte Kontext-Whitelist
(facts key/value/note, keine Personendaten), Pilot-Workflow
@@ -37,7 +37,16 @@ verlinkt; danach D25/M4-Planung.
Draft-Payslip, Agent liefert strukturiertes Verdict), Agent-M4.2:
`mode=review` plus context-Schema, drei Beweisklassen, Injection-Abgrenzung
und Feature-Flag `PV_REVIEW_MODE` (default aus). Nicht-Ziele fixiert.
Offen: Mapping Odoo-KV-SI-Codes ↔ KB kv-kvt-IDs.
- **KV-Varianten-Mapping (Odoo ↔ KB, 2026-09-16):** Die KV-Library führt
bereits `wko/match-report.json` (wko_slug → oegb_variant_id; 407 matched /
32 low / 175 unmatched). `tools/build_kv_variant_map.py` erzeugt daraus
`tools/catalogs/kv_variant_map.json`: 105 Varianten (98 matched, 7 low),
439/614 KB-Einträge abgedeckt; je Variante `docs` mit kv_kvt_id + slug +
doctype als Paare, Low-Confidence markiert, Rest in `unmatched_kb_entries`
(WKO-aktuelle Dokumente ohne ÖGB-Gegenstück, z. B. KV-Abschluss-News).
Odoo kann pro `library_variant_id` die zugehörigen KB-Einträge auflösen;
Seed-Abdeckung SI-2203/SI-2748 testet
`tests/test_kv_variant_map.py`. Tests gesamt **98 grün**.
## Completed (2026-09-14)
+10 -4
View File
@@ -436,10 +436,16 @@ im Agenten per Feature-Flag (`PV_REVIEW_MODE`, default aus) freigeschaltet.
- **KV-Katalog in Odoo:** `l10n.at.payroll.kv` (+ versionierte `kv.wert`,
Gruppen/Stufen, Import-Wizard per CSV-Paste mit Sprungwarnung >10 %).
`library_variant_id` verlinkt auf die KV-Library-Variante (z. B. SI-2203,
Seed SI-2203/SI-2748). **Offener Punkt:** unser KB-Katalog kennt nur
`kv-kvt-NNN` (WKO-Dokumente, keine SI-IDs) — die Brücke Odoo-KV ↔ KB-KV
braucht eine Mapping-Tabelle (via KV-Library-Katalog des Schwesterprojekts
bzw. Branchen-/Titelabgleich).
Seed SI-2203/SI-2748). **Brücke gebaut (2026-09-16):** die KV-Library führt
bereits einen WKO-Match-Report (`wko/match-report.json`: wko_slug →
oegb_variant_id, 407 matched / 32 low / 175 unmatched); daraus erzeugt
`tools/build_kv_variant_map.py` die versionierte Map
`tools/catalogs/kv_variant_map.json` (105 Varianten, 439/614 KB-Einträge
abgedeckt; Docs je Variante mit kv_kvt_id+slug+doctype; Low-Confidence
markiert). Odoo kann pro `library_variant_id` die zugehörigen KB-Einträge
auflösen; die 175 unmatched sind WKO-aktuelle Dokumente ohne ÖGB-
Gegenstück (z. B. KV-Abschluss-News). Tests: `tests/test_kv_variant_map.py`
(inkl. Seed-Abdeckung SI-2203/SI-2748).
- Vertragsfelder für Kontext-Whitelist vorhanden: `hr.version` (KV, Gruppe,
Erfahrungsstufe, Überzahlung, Vordienstzeiten), Company (Bundesland,
KommSt-Gemeinde, NSchAB).
+76
View File
@@ -0,0 +1,76 @@
"""Tests für die KV-Varianten-Map (Odoo library_variant_id ↔ KB kv-kvt-IDs)."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def load_map() -> dict:
return json.loads(
(ROOT / "tools" / "catalogs" / "kv_variant_map.json").read_text(encoding="utf-8")
)
def load_kb_catalog() -> dict:
return json.loads(
(ROOT / "tools" / "catalogs" / "kv_catalog.json").read_text(encoding="utf-8")
)
def test_map_structure_and_kb_id_integrity():
kb = load_kb_catalog()
kb_ids = {e["id"] for e in kb["entries"].values()}
variant_map = load_map()
variants = variant_map["variants"]
assert variants, "Mapping muss Varianten enthalten"
referenced = set()
for vid, variant in variants.items():
assert variant["variant_id"] == vid
assert variant["docs"], f"leere Variante: {vid}"
assert variant["confidence"] in ("matched", "low")
for doc in variant["docs"]:
assert {"kv_kvt_id", "slug", "doctype"} <= set(doc)
referenced.add(doc["kv_kvt_id"])
assert referenced <= kb_ids, "Mapping darf nur existierende KB-IDs führen"
# Unmatched-Einträge sind ebenfalls echte KB-IDs und decken den Rest ab
unmatched = variant_map["unmatched_kb_entries"]
assert set(unmatched) <= kb_ids
stats = variant_map["stats"]
assert stats["n_kb_entries_covered"] + stats["n_kb_entries_unmatched"] == (
stats["n_kb_entries_total"]
)
assert stats["n_variants"] == len(variants)
def test_odoo_seed_variants_are_covered():
"""Odoo-Seed-KVs (kv_seed.xml): SI-2203 Handwerk und Gewerbe,
SI-2748 Metallgewerbe müssen KV- und Tabellendokumente führen."""
variants = load_map()["variants"]
seeds = {vid: variants.get(vid) for vid in ("SI-2203_de", "SI-2748_de")}
assert seeds["SI-2203_de"] and seeds["SI-2203_de"]["confidence"] == "matched"
assert seeds["SI-2748_de"] and seeds["SI-2748_de"]["confidence"] == "matched"
slugs_2203 = {d["slug"] for d in seeds["SI-2203_de"]["docs"]}
slugs_2748 = {d["slug"] for d in seeds["SI-2748_de"]["docs"]}
assert "kollektivvertrag-gewerbe-handwerk-und-dienstleistung-2026" in slugs_2203
assert "gehaltsordnung-gewerbe-handwerk-dienstleistung-2026" in slugs_2203
assert "kollektivvertrag-abschluss-angestellte-metallgewerbe-2026" in slugs_2748
assert "lohnordnung-metallgewerbe-arbeiter-2026" in slugs_2748
def test_doc_pairs_keep_slug_and_id_correspondence():
kb = load_kb_catalog()
by_id = {e["id"]: e for e in kb["entries"].values()}
variant_map = load_map()
checked = 0
for variant in variant_map["variants"].values():
for doc in variant["docs"]:
entry = by_id[doc["kv_kvt_id"]]
url_slug = entry["url"].rstrip("/").rsplit("/", 1)[-1]
assert url_slug == doc["slug"], (
f"{doc['kv_kvt_id']}: {url_slug} != {doc['slug']}"
)
checked += 1
assert checked >= 400
+143
View File
@@ -0,0 +1,143 @@
"""Erzeugt `tools/catalogs/kv_variant_map.json`: KV-Library-Varianten
(``SI-xxxx_de`` identisch mit `library_variant_id` im Odoo-Katalog
``l10n.at.payroll.kv``) KB-Einträge ``kv-kvt-NNN``.
Brücke ist der Match-Report der KV-Library (``wko/match-report.json``:
wko_slug oegb_variant_id mit Score). Unsere KB-Einträge sind über den
WKO-Dokument-Slug verknüpft (Katalog-Key ``<slug>.html``, URL-Slug identisch).
Deterministisch re-runnable; der KV-Library-Pfad liegt im Schwesterprojekt:
python3 tools/build_kv_variant_map.py [--kv-library ../odoo-at-payroll/personalverrechnung/quellen/kv]
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_KV_LIBRARY = REPO_ROOT.parent / "odoo-at-payroll" / "personalverrechnung" / "quellen" / "kv"
def build(kv_library: Path) -> dict:
kb_catalog = json.loads(
(REPO_ROOT / "tools" / "catalogs" / "kv_catalog.json").read_text(encoding="utf-8")
)
lib_catalog = json.loads((kv_library / "kv-catalog.json").read_text(encoding="utf-8"))
match = json.loads((kv_library / "wko" / "match-report.json").read_text(encoding="utf-8"))
variant_meta = {e["variant_id"]: e for e in lib_catalog["entries"]}
# slug -> KB-Eintrag (Katalog-Key ist "<slug>.html"; URL-Slug als Kontrolle)
slug_to_kb: dict[str, dict] = {}
for filename, entry in kb_catalog["entries"].items():
slug = entry["url"].rstrip("/").rsplit("/", 1)[-1]
if not slug:
slug = filename.removesuffix(".html")
slug_to_kb[slug] = entry
variants: dict[str, dict] = {}
unmatched: dict[str, dict] = {}
def bucket(variant_id: str, confidence: str) -> dict:
if variant_id not in variants:
meta = variant_meta.get(variant_id, {})
variants[variant_id] = {
"variant_id": variant_id,
"variant_group_id": meta.get("variant_group_id"),
"title": meta.get("title"),
"chamber": meta.get("chamber"),
"docs": [],
"confidence": confidence,
}
return variants[variant_id]
for row in match["matches"]:
slug = row["wko_slug"]
kb = slug_to_kb.get(slug)
if kb is None:
continue
b = bucket(row["oegb_variant_id"], "matched")
b["docs"].append(
{"kv_kvt_id": kb["id"], "slug": slug, "doctype": kb.get("doctype")}
)
for row in match.get("low_confidence", []):
slug = row["wko_slug"]
kb = slug_to_kb.get(slug)
if kb is None:
continue
#matched schlägt low_confidence für denselben Slug
already = any(
kb["id"] in {d["kv_kvt_id"] for d in v["docs"]}
for v in variants.values()
)
if already:
continue
b = bucket(row["best_oegb_variant_id"], "low")
b["docs"].append(
{"kv_kvt_id": kb["id"], "slug": slug, "doctype": kb.get("doctype")}
)
covered_ids = {
d["kv_kvt_id"] for v in variants.values() for d in v["docs"]
}
for filename, entry in kb_catalog["entries"].items():
if entry["id"] not in covered_ids:
slug = entry["url"].rstrip("/").rsplit("/", 1)[-1] or filename.removesuffix(".html")
unmatched[entry["id"]] = {"slug": slug, "title": entry["title"]}
by_id = {v["id"]: v for v in kb_catalog["entries"].values()}
for v in variants.values():
v["docs"] = sorted(
v["docs"], key=lambda d: by_id[d["kv_kvt_id"]]["id"]
)
return {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"sources": {
"kb_catalog": "tools/catalogs/kv_catalog.json",
"kv_library": str(kv_library),
},
"stats": {
"n_variants": len(variants),
"n_variants_matched": sum(
1 for v in variants.values() if v["confidence"] == "matched"
),
"n_variants_low_confidence": sum(
1 for v in variants.values() if v["confidence"] == "low"
),
"n_kb_entries_covered": len(covered_ids),
"n_kb_entries_total": len(kb_catalog["entries"]),
"n_kb_entries_unmatched": len(unmatched),
"kv_library_report": {
"n_matched": match["n_matched"],
"n_low_confidence": match["n_low_confidence"],
"n_unmatched_wko_current": match["n_unmatched_wko_current"],
},
},
"variants": dict(sorted(variants.items())),
"unmatched_kb_entries": dict(sorted(unmatched.items())),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--kv-library", type=Path, default=DEFAULT_KV_LIBRARY,
help="KV-Library-Verzeichnis (kv-catalog.json + wko/match-report.json)",
)
args = parser.parse_args()
out = build(args.kv_library)
target = REPO_ROOT / "tools" / "catalogs" / "kv_variant_map.json"
target.write_text(
json.dumps(out, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
print(json.dumps(out["stats"], ensure_ascii=False, indent=1))
print("geschrieben:", target)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff