mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
feat(tools): map kv library variants to kb ids
This commit is contained in:
+21
-12
@@ -5,14 +5,14 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
|
|||||||
|
|
||||||
## Current focus
|
## Current focus
|
||||||
|
|
||||||
D24 ist validiert (beide 500-Euro-Fragen verifiziert beantwortet; think-Fix
|
D24 validiert (500-Euro-Fragen verifiziert, think-Fix, Offline-Eval Recall@8
|
||||||
aktiv; Offline-Eval Recall@8 0,95; 95 Unit-Tests). M4-Richtung mit User
|
0,95). D25-Planung steht (planung.md Abschnitt 14): Odoo orchestriert und
|
||||||
geklärt (D25-Vorentscheidung): Odoo orchestriert und rechnet (System of
|
rechnet (System of Record), Agent prüft Plausibilität, kein Rückpfad;
|
||||||
Record), der Agent konsumiert nur das vorgegebene Ergebnis und prüft
|
Privacy-Regel 8 wird erst per Feature-Flag `PV_REVIEW_MODE` im M4 aufgeweicht.
|
||||||
Plausibilität gegen die KB. Die Aufweichung der Privacy-Regel 8 (keine
|
Modul-Review abgeschlossen (`.oddo-module/`), KV-Varianten-Mapping gebaut
|
||||||
Lohndaten im Prompt) passiert bewusst erst im Rahmen der Odoo-Implementierung;
|
(`tools/catalogs/kv_variant_map.json`, 439/614 abgedeckt, Seeds SI-2203/
|
||||||
der Test-Agent bleibt `knowledge_base_only`. Odoo-Modul-Bestand wird vom User
|
SI-2748 getestet). Als Nächstes: D25-Umsetzung — Agent-`review`-Modus (M4.2)
|
||||||
verlinkt; danach D25/M4-Planung.
|
und Odoo-Modul `l10n_at_payroll_agent` (M4.1).
|
||||||
|
|
||||||
## Completed (2026-09-16, Modul-Review/D25-Planung)
|
## 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),
|
×Jahr, DAG), lohnsteuer.py (§ 66/67/68 mit YTD-Freibetragsverbrauch),
|
||||||
payslip_private (KommSt/DZ/FLAF-DB/SZ), sachbezuege/reisekosten. Parameter
|
payslip_private (KommSt/DZ/FLAF-DB/SZ), sachbezuege/reisekosten. Parameter
|
||||||
aus ÖGK-TASY-Export gegen offiziellen Report gespiegelt; 2027-Rahmen.
|
aus ÖGK-TASY-Export gegen offiziellen Report gespiegelt; 2027-Rahmen.
|
||||||
KV-Katalog `l10n.at.payroll.kv` mit versionierten `kv.wert`, Gruppen/Stufen,
|
KV-Katalog `l10n.at.payroll.kv` mit versionierten `kv.wert`, Gruppen/Stufen
|
||||||
CSV-Import-Wizard (Sprungwarnung >10 %) und `library_variant_id` auf die
|
und CSV-Import-Wizard (Sprungwarnung >10 %); `library_variant_id` verlinkt
|
||||||
KV-Library (SI-2203/SI-2748). D25-Planung steht in `planung.md`
|
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
|
Abschnitt 14: neues Modul `l10n_at_payroll_agent`, ir.config_parameter plus
|
||||||
eigene Gruppe, serverseitiger Client, hart kodierte Kontext-Whitelist
|
eigene Gruppe, serverseitiger Client, hart kodierte Kontext-Whitelist
|
||||||
(facts key/value/note, keine Personendaten), Pilot-Workflow
|
(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:
|
Draft-Payslip, Agent liefert strukturiertes Verdict), Agent-M4.2:
|
||||||
`mode=review` plus context-Schema, drei Beweisklassen, Injection-Abgrenzung
|
`mode=review` plus context-Schema, drei Beweisklassen, Injection-Abgrenzung
|
||||||
und Feature-Flag `PV_REVIEW_MODE` (default aus). Nicht-Ziele fixiert.
|
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)
|
## Completed (2026-09-14)
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -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`,
|
- **KV-Katalog in Odoo:** `l10n.at.payroll.kv` (+ versionierte `kv.wert`,
|
||||||
Gruppen/Stufen, Import-Wizard per CSV-Paste mit Sprungwarnung >10 %).
|
Gruppen/Stufen, Import-Wizard per CSV-Paste mit Sprungwarnung >10 %).
|
||||||
`library_variant_id` verlinkt auf die KV-Library-Variante (z. B. SI-2203,
|
`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
|
Seed SI-2203/SI-2748). **Brücke gebaut (2026-09-16):** die KV-Library führt
|
||||||
`kv-kvt-NNN` (WKO-Dokumente, keine SI-IDs) — die Brücke Odoo-KV ↔ KB-KV
|
bereits einen WKO-Match-Report (`wko/match-report.json`: wko_slug →
|
||||||
braucht eine Mapping-Tabelle (via KV-Library-Katalog des Schwesterprojekts
|
oegb_variant_id, 407 matched / 32 low / 175 unmatched); daraus erzeugt
|
||||||
bzw. Branchen-/Titelabgleich).
|
`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,
|
- Vertragsfelder für Kontext-Whitelist vorhanden: `hr.version` (KV, Gruppe,
|
||||||
Erfahrungsstufe, Überzahlung, Vordienstzeiten), Company (Bundesland,
|
Erfahrungsstufe, Überzahlung, Vordienstzeiten), Company (Bundesland,
|
||||||
KommSt-Gemeinde, NSchAB).
|
KommSt-Gemeinde, NSchAB).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user