Prompt v2 + Kontext-Section-Prioritaet: Fehlverweigerungen behoben (D8)

Diagnose: q-008 bekam die 'Verweise'-Navigationssektion von lb-end-03 als
Kontext (BM25-Langennormalisierung bevorzugt duenne Chunks) -> inhaltlose
Bloecke -> korrekte Verweigerung nach Regel 4. q-031 enthaelt eine falsche
Praemisse (kein 'Mindestlohngesetz' in Oesterreich; Wissensbasis sagt:
kein gesetzlich betraglich festgelegtes Mindestentgelt).

Fixes: (a) retrieve.py _representative_chunk - pro Eintrag beste Inhalts-
sektion (Zusammenfassung > Kernwerte > Rechtsgrundlagen > Payroll >
sonstige > Verweise), Section-Swap aus dem Index falls nur 'Verweise'
rangiert; (b) generate.py Prompt v2 - Regel 4 erlaubt Teilantworten,
Regel 8 verlangt Prämisse-Korrektur mit Muster-Beispiel.

Bestaetigungslauf qwen3.8:27b (35 Fragen): Zitier-Praezision 100 % (8
Regenerierungen, alle geheilt), Verweigerung korrekt 94,3 %, erwartete
Quelle 83,9 % (v1 80,6 %), mean 34 s. q-008 + q-031 behoben; verbleibender
bekannter Fall q-029 (breite Survey-Frage, sicherer Fehlermodus).
Retrieval-Metriken unveraendert (Recall@8 0,952). 41 Tests gruen.
This commit is contained in:
2026-09-15 00:22:41 +02:00
parent 6cd37429f3
commit eb1a768876
8 changed files with 227 additions and 15 deletions
+18 -5
View File
@@ -58,11 +58,13 @@ zu planen).
- **Push**: Remote localhost:3003 aus der Sandbox nicht erreichbar — User - **Push**: Remote localhost:3003 aus der Sandbox nicht erreichbar — User
pusht vom Host. pusht vom Host.
- **Fehlverweigerungen**: q-008 (Abfertigung/Verfügungsmöglichkeiten), - **Fehlverweigerungen — Stand nach Prompt v2:** q-008 und q-031 sind
q-031 (Mindestlohngesetz) — breite Fragen, Retrieval erfolgreich, Modell **behoben** (Section-Priorität + Regel 8). Verbleibend: q-029
verweigert. M3-Tuning: Regel-4-Formulierung lockern („behandle den („Welche Neuerungen behandelt WIKU Personal aktuell 2026?“) — breite
behandelten Teil“) oder Kontextblöcke erhöhen. Vorher Prompt einfrieren Survey-Frage über 12 Hefte; Kontext enthält echte Zusammenfassungen,
für den Bake-off-Vergleich. Modell verweigert trotzdem (sicherer Fehlermodus). Mögliche spätere
Hebel: Survey-Rule im Prompt, mehr Kontextblöcke oder Map-Reduce-artige
Zusammenfassung. Weitere Iterationen erst mit neuen Evaluationsdaten.
- **Latenz**: mean 32 s/ Antwort ist hoch (Dense 27B + große Prompts). - **Latenz**: mean 32 s/ Antwort ist hoch (Dense 27B + große Prompts).
Hebel: weniger Kontextblöcke (aktuell 8+6), schnellere Kandidaten. Hebel: weniger Kontextblöcke (aktuell 8+6), schnellere Kandidaten.
- **Cloud-Modelle** (`*:cloud` auf der Ollama-Instanz) sind für Antworten - **Cloud-Modelle** (`*:cloud` auf der Ollama-Instanz) sind für Antworten
@@ -92,6 +94,17 @@ zu planen).
(100 % Zitier-Präzision, 94,3 % Verweigerung korrekt). `gemma4:26b` als (100 % Zitier-Präzision, 94,3 % Verweigerung korrekt). `gemma4:26b` als
dokumentierter Latenz-Kandidat; ein Modellwechsel läuft nur erneut über dokumentierter Latenz-Kandidat; ein Modellwechsel läuft nur erneut über
das dokumentierte Protokoll (Skill). das dokumentierte Protokoll (Skill).
- **D8 (Prompt-Tuning 2026-09-14):** Prompt v2 + Kontext-Section-
Priorität. (a) Regel 4 erlaubt Teilantworten bei unvollständiger Deckung;
Regel 8 verlangt Prämisse-Korrektur statt Verweigerung (Muster-Beispiel
„Mindestlohngesetz“). (b) `_representative_chunk` wählt pro Eintrag die
beste **Inhaltssektion** (Zusammenfassung > Kernwerte > Rechtsgrundlagen
> Payroll > sonstige; „Verweise“ nur als letzter Rückgriff — BM25
rangiert die dünnen Navigations-Chunks bevorzugt, q-008-Ursache).
Ergebnis v1→v2: Zitier-Präzision 100 % gehalten; q-008 + q-031 behoben;
erwartete Quelle 80,6 → 83,9 %; Verweigerung 94,3 % (Fehler getauscht:
neu q-029 — breite Survey-Frage über 12 Hefte, sicherer Fehlermodus).
Report `data/eval-qwen38-v2.json`.
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
korrektheit > Latenz. korrektheit > Latenz.
+9 -3
View File
@@ -79,9 +79,15 @@ MRR 0,690 — M1-Ziel >0,9 erreicht (BM25-only war 0,855; die vier
BM25-Fehltreffer behebt die Dense-Suche alle). BM25-Fehltreffer behebt die Dense-Suche alle).
**Antworten** (Bake-off-Sieger qwen3.8:27b, Thinking aus, Temperatur 0,1): **Antworten** (Bake-off-Sieger qwen3.8:27b, Thinking aus, Temperatur 0,1):
**Zitier-Präzision 100 %** (4 Zitierverletzungen wurden von der **Zitier-Präzision 100 %** · Verweigerung korrekt 94,3 % · erwartete Quelle
Post-Validierung abgefangen und regeneriert) · Verweigerung korrekt zitiert **83,9 %** · Latenz mean 34 s / p95 54 s.
94,3 % · erwartete Quelle zitiert 80,6 % · Latenz mean 32 s / p95 53 s.
**Prompt v2 + Kontext-Section-Priorität (2026-09-14):** Teilantworten bei
unvollständiger Deckung erlaubt (Regel 4), Prämisse-Korrektur statt
Verweigerung (Regel 8), pro Eintrag beste Inhaltssektion als Kontextblock
(Zusammenfassung > Kernwerte > … > Verweise zuletzt — Navigations-Chunks
lösen keine Fehlverweigerungen mehr aus). v1→v2: q-008 + q-031 behoben,
erwartete Quelle 80,6 % → 83,9 %, Zitier-Präzision unverändert 100 %.
**Modell-Bake-off (M3, 2026-09-14)** — Entscheidung: **qwen3.8:27b** **Modell-Bake-off (M3, 2026-09-14)** — Entscheidung: **qwen3.8:27b**
(Protokoll: Zitier-Präzision → Verweigerungskorrektheit → Latenz): (Protokoll: Zitier-Präzision → Verweigerungskorrektheit → Latenz):
+4
View File
@@ -99,6 +99,10 @@ questions:
- id: q-031 - id: q-031
question: "Was regelt das Mindestlohngesetz und für wen gilt es?" question: "Was regelt das Mindestlohngesetz und für wen gilt es?"
expected_ids: [lb-ent-09] expected_ids: [lb-ent-09]
note: "Falsche Prämisse — ideale Antwort korrigiert: kein gesetzlich
betraglich festgelegtes Mindestentgelt; Untergrenze via KV/Satzung/
Mindestlohntarif (BEA) belegt aus lb-ent-09."
tags: [premise-correction]
# --- Verweigerungsfälle (Antwortmodus) --- # --- Verweigerungsfälle (Antwortmodus) ---
- id: r-001 - id: r-001
+10 -1
View File
@@ -33,12 +33,21 @@ Verbindliche Regeln:
IDs, die nur im Fließtext als Verweis genannt werden, sind Querverweise IDs, die nur im Fließtext als Verweis genannt werden, sind Querverweise
und KEINE Belege. und KEINE Belege.
3. Gib jeden Wert mit seinem Stand an, z. B. „28,5 % (Stand 2026-01)“. 3. Gib jeden Wert mit seinem Stand an, z. B. „28,5 % (Stand 2026-01)“.
4. Beantworten die Kontextblöcke die Frage nicht, antworte exakt: 4. Beantworte die Frage mit den fachlichen Aussagen der thematisch
relevanten Blöcke — auch wenn sie die Frage nur teilweise decken;
mache klar, welcher Aspekt belegt ist. Verweigere nur, wenn KEIN
Block thematisch zur Frage passt, mit exakt:
„Dazu enthält die Wissensbasis keine Aussage.“ — und schlage nichts vor. „Dazu enthält die Wissensbasis keine Aussage.“ — und schlage nichts vor.
5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und 5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und
kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf. kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf.
6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt. 6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt.
7. Antworte auf Deutsch und prägnant (Stichpunkte, wo sinnvoll). 7. Antworte auf Deutsch und prägnant (Stichpunkte, wo sinnvoll).
8. Baut die Frage auf einer falschen Annahme auf (z. B. ein nicht
existierendes Gesetz), korrigiere die Annahme anhand der Blöcke und
gib die zutreffende, belegte Aussage. Muster: Auf „Was regelt das
Mindestlohngesetz?“ antworte sinngemäß „Ein Mindestlohngesetz existiert
laut Kontext nicht; stattdätzlich gilt …“ — mit Beleg [ID].
Verweigere in diesem Fall nicht.
Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort.""" Verletze Regel 2 oder Regel 4 niemals — im Zweifel verweigere die Antwort."""
+53 -5
View File
@@ -34,6 +34,29 @@ class ChunkResult:
source: str = "fused" # bm25 | dense | fused | cross_ref source: str = "fused" # bm25 | dense | fused | cross_ref
def _section_priority(section: str) -> int:
"""Kontext-Sektionen priorisieren: Inhalt vor Navigation.
„Verweise“-Sektionen sind Navigationslisten (KB-IDs) — sie tragen
Retrieval-Signal (Stichworte), sind aber als Kontextblock wertlos und
provozieren Fehlverweigerungen. BM25-Längennormalisierung rangiert sie
bevorzugt, daher wird pro Eintrag bewusst die beste Inhaltssektion
gewählt (Fix 2026-09-14, q-008).
"""
s = (section or "").casefold()
if s.startswith("zusammenfassung"):
return 0
if s.startswith("kernwerte"):
return 1
if s.startswith("rechtsgrundlagen"):
return 2
if s.startswith("payroll"):
return 3
if s.startswith("verweise"):
return 5
return 4
class Retriever: class Retriever:
def __init__(self, cfg: Config, db_path: str | None = None, client=None): def __init__(self, cfg: Config, db_path: str | None = None, client=None):
self.cfg = cfg self.cfg = cfg
@@ -182,6 +205,28 @@ class Retriever:
source=source, source=source,
) )
def _representative_chunk(
self, entry_id: str, ranked: list[ChunkResult]
) -> ChunkResult:
"""Beste Inhaltssektion des Eintrags als Kontextblock.
Bevorzugt die rangierte (gefundene) Sektion mit bester Priorität;
traf der Eintrag nur über „Verweise“, wird seine beste Inhalts-
sektion aus dem Index nachgeladen (source="section-swap").
"""
content = [c for c in ranked if _section_priority(c.section) < 5]
if content:
return min(content, key=lambda c: (_section_priority(c.section), -c.score))
rows = self._con.execute(
"SELECT * FROM chunks WHERE entry_id = ? AND section NOT LIKE 'Verweise%' "
"ORDER BY CASE WHEN section LIKE 'Zusammenfassung%' THEN 0 "
"WHEN section LIKE 'Kernwerte%' THEN 1 ELSE 2 END, chunk_id LIMIT 1",
(entry_id,),
).fetchall()
if rows:
return self._row_to_result(rows[0], 0.0, "section-swap")
return ranked[0] # Eintrag hat nur Verweise-Sektionen
def _best_chunk_of_entry(self, entry_id: str) -> ChunkResult | None: def _best_chunk_of_entry(self, entry_id: str) -> ChunkResult | None:
rows = self._con.execute( rows = self._con.execute(
"SELECT * FROM chunks WHERE entry_id = ? " "SELECT * FROM chunks WHERE entry_id = ? "
@@ -224,16 +269,19 @@ class Retriever:
results.append(self._row_to_result(rows[cid], score, source)) results.append(self._row_to_result(rows[cid], score, source))
results.sort(key=lambda r: -r.score) results.sort(key=lambda r: -r.score)
# Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup) # Bester Chunk je Eintrag -> Kontext (Entry-Level-Dedup).
# Der Vertreter-Chunk ist die beste Inhaltssektion des Eintrags,
# nicht die Rangfolge-Beste (vgl. _section_priority).
main: list[ChunkResult] = [] main: list[ChunkResult] = []
seen: set[str] = set() seen: set[str] = set()
by_entry: dict[str, list[ChunkResult]] = {}
for r in results: for r in results:
if r.entry_id in seen: by_entry.setdefault(r.entry_id, []).append(r)
continue for entry_id, chunks in by_entry.items():
seen.add(r.entry_id)
main.append(r)
if len(main) >= n: if len(main) >= n:
break break
seen.add(entry_id)
main.append(self._representative_chunk(entry_id, chunks))
# cross_ref-Erweiterung (kontrolliert, markiert, begrenzt) # cross_ref-Erweiterung (kontrolliert, markiert, begrenzt)
extra: list[ChunkResult] = [] extra: list[ChunkResult] = []
+10
View File
@@ -271,6 +271,16 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
vorhandener Zitate) und 37,5 s mean — Rang 5 von 6, schlägt qwen3.8 vorhandener Zitate) und 37,5 s mean — Rang 5 von 6, schlägt qwen3.8
in keiner Kennzahl. q-008 und q-031 verweigern alle Top-Kandidaten — in keiner Kennzahl. q-008 und q-031 verweigern alle Top-Kandidaten —
Prompt-/Retrieval-Tuning-Thema, kein Modellthema. Prompt-/Retrieval-Tuning-Thema, kein Modellthema.
**Prompt v2 + Kontext-Tuning (2026-09-14):** (a) Regel 4 erlaubt
Teilantworten, Regel 8 verlangt Prämisse-Korrektur mit Muster-Beispiel;
(b) Kontextblöcke pro Eintrag = beste Inhaltssektion statt bester
Rangfolge-Chunk („Verweise“-Sektionen zuletzt — BM25-Längennormalisierung
rangiert die dünnen Navigations-Chips bevorzugt, Ursache q-008).
Bestätigungslauf qwen3.8:27b (35 Fragen): Zitier-Präzision **100 %**,
Verweigerung korrekt 94,3 % (q-008/q-031 behoben; neuer bekannter Fall
q-029 — breite Survey-Frage, sicherer Fehlermodus), erwartete Quelle
**83,9 %** (v1: 80,6 %), mean 34 s. Report `data/eval-qwen38-v2.json`.
- **M4 offen:** Odoo-Integration (separater Plan nach Verifikation der - **M4 offen:** Odoo-Integration (separater Plan nach Verifikation der
Odoo-19-LLM-Module). Odoo-19-LLM-Module).
- Betrieb: `agent/README.md`. - Betrieb: `agent/README.md`.
+1
View File
@@ -4,3 +4,4 @@ httpx>=0.27
PyYAML>=6.0 PyYAML>=6.0
numpy>=2.0 numpy>=2.0
pytest>=8.0 pytest>=8.0
beautifulsoup4>=4.12
+121
View File
@@ -0,0 +1,121 @@
"""Gemeinsame Konstanten für die Wissensbasis-Tools (Intake + Registry).
Quellen-ID-Räume: `lb` (Lexis Briefings), `wk` (WIKU Personal), `kv`
(WKO.at Kollektivvertrag-Seiten), `ris` (RIS-Gesetze). Die bestehende
Cluster-Map (topic -> ID-Präfix) der lb/wk-Einträge wird von
`build_registry.py` aus den Layer-2-Frontmatterdaten abgeleitet; nur die
Cluster der neuen Quellen sind hier deklariert.
LAW_MAP begründet je Gesetz topic/chapter aus dem tatsächlichen Inhalt
der .ris-Dateien (Stand 2026-09-15, vgl. §-Inventar der Quelldateien):
Gesetze ohne natürlichen Cluster bekommen einen eigenen Rechtsgebiet-
Cluster oder landen bewusst im Sammel-Cluster `normen-sonstige`.
"""
from __future__ import annotations
import re
# Alle ID-Räume des Korpus (Schema-Beschreibung für kb.json).
ID_SPACES = {
"lb": "Lexis Briefings Personalrecht (Lexis 360-Export)",
"wk": "WIKU Personal (Fachbroschüren, Arbeitsunterlagen, Casebook, Personal aktuell)",
"kv": "WKO.at — Kollektivvertrag-Dokumente (Lohn-/Gehaltsordnungen, KV-Texte)",
"ris": "RIS — Rechtsinformationssystem des Bundes (Gesetzes-§-Auschnitte)",
}
# Neue Cluster der Quellen kv/ris: slug -> (ID-Präfix, Anzeigename).
NEW_CLUSTERS = {
"kollektivvertraege": ("kvt", "Kollektivverträge (WKO.at)"),
"zivilrecht-normen": ("zvr", "Zivilrechtliche Normen (ABGB, ZPO & Co.)"),
"arbeitsvertragsrecht": ("avr", "Arbeitsvertragsrecht (AVRAG)"),
"arbeitsgerichtsbarkeit": ("agg", "Arbeits- und Sozialgerichtsbarkeit (ASGG)"),
"lohnsteuer": ("lst", "Lohnsteuer & Einkommensteuer (EStG)"),
"abgabenverfahren": ("abo", "Abgabenverfahren (BAO)"),
"normen-sonstige": ("nso", "Weitere Gesetze und Verordnungen"),
}
# Werk-Namen der neuen Quellen (Frontmatter `work`).
WORK_KV = "WKO.at Kollektivvertrag"
WORK_RIS = "RIS Rechtsinformationssystem des Bundes"
# .ris/<Datei ohne .md> -> (topic, chapter, lfd. Nummer im Cluster).
# Nummern sind fix (dann eingefroren); neue Gesetze hängen in ihrem Cluster
# an der höchsten Nummer an.
LAW_MAP: dict[str, tuple[str, str, int]] = {
"ABGB": ("zivilrecht-normen", "Zivilrecht", 1),
"IPRG": ("zivilrecht-normen", "Zivilrecht", 2),
"KSchG": ("zivilrecht-normen", "Verbraucherschutz", 3),
"ZPO": ("zivilrecht-normen", "Zivilrecht", 4),
"APG": ("pension", "Pensionsrecht", 1),
"APSG": ("arbeitszeitmodelle", "Arbeitsrecht (Kurzarbeit)", 1),
"APflG": ("lehrlinge", "Arbeitsrecht (Lehrverhältnis)", 1),
"BAG": ("lehrlinge", "Arbeitsrecht (Lehrverhältnis)", 2),
"ARG": ("ruhezeiten", "Arbeitsrecht", 1),
"ASGG": ("arbeitsgerichtsbarkeit", "Arbeits- und Sozialgerichtsbarkeit", 1),
"ASVG": ("beitragsrecht-asvg", "Sozialversicherungsrecht", 1),
"ASchG": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 1),
"BS-V": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 2),
"BauV": ("arbeitnehmerschutz", "Arbeitnehmerschutz", 3),
"AVRAG": ("arbeitsvertragsrecht", "Arbeitsrecht", 1),
"AZG": ("arbeitszeitgrenzen", "Arbeitsrecht", 1),
"AktG": ("vorstand", "Gesellschaftsrecht", 1),
"AlVG": ("altersteilzeit", "Sozialversicherungsrecht", 1),
"AngG": ("beendigungsarten", "Arbeitsrecht", 1),
"LAG": ("beendigungsarten", "Arbeitsrecht", 2),
"ArbIG": ("normen-sonstige", "Arbeitsrecht", 1),
"RStDG": ("normen-sonstige", "Dienstrecht", 2),
"StGB": ("normen-sonstige", "Strafrecht", 3),
"StPO": ("normen-sonstige", "Strafrecht", 4),
"UGB": ("normen-sonstige", "Gesellschaftsrecht", 5),
"ArbVG": ("betriebsrat", "Arbeitsverfassungsrecht", 1),
"AÜG": ("arbeitskrafteuberlassung", "Arbeitsrecht", 1),
"BAO": ("abgabenverfahren", "Steuerrecht", 1),
"BBG": ("behinderte", "Sozialrecht", 1),
"BEinstG": ("behinderte", "Sozialrecht", 2),
"BMSVG": ("vorsorgeleistungen", "Betriebliche Vorsorge", 1),
"PKG": ("vorsorgeleistungen", "Betriebliche Vorsorge", 2),
"FlexKapGG": ("vorsorgeleistungen", "Gesellschaftsrecht", 3),
"BSVG": ("bauerliche-sozialversicherung", "Sozialversicherungsrecht", 1),
"BUAG": ("endabrechnung", "Arbeitsrecht", 1),
"DHG": ("dienstnehmerhaftung", "Arbeitsrecht", 1),
"EFZG": ("krankenstand", "Arbeitsrecht", 1),
"EO": ("lohnpfandung", "Exekutionsrecht", 1),
"EStG": ("lohnsteuer", "Steuerrecht", 1),
"FLAG": ("kinderbetreuungsgeld", "Familienleistungen", 1),
"KBGG": ("kinderbetreuungsgeld", "Familienleistungen", 2),
"FamZeitbG": ("familienzeit", "Familienleistungen", 1),
"GSVG": ("gsvg-fsvg", "Sozialversicherungsrecht", 1),
"GewO": ("gewerbe", "Gewerberecht", 1),
"GlBG": ("gleichbehandlung", "Arbeitsrecht", 1),
"GmbHH_PLACEHOLDER": ("geschaftsfuhrer", "Gesellschaftsrecht", 1),
"IESG": ("insolvenz-betriebsubergang", "Insolvenzrecht", 1),
"KJBG": ("jugendliche", "Jugendschutz", 1),
"LSD-BG": ("lohndumping", "Arbeitsrecht", 1),
"MSchG": ("schwangerschaft", "Mutterschutz", 1),
"MSchV": ("schwangerschaft", "Mutterschutz", 2),
"NSchG": ("nachtschwerarbeit", "Arbeitsrecht", 1),
"Sachbezugswerteverordnung": ("sachbezuge", "Steuerrecht", 1),
"Schwerarbeitsverordnung": ("schwerarbeit", "Arbeitsrecht", 1),
"UrlG": ("urlaub", "Arbeitsrecht", 1),
"TAG": ("urlaub", "Arbeitsrecht", 2),
"VBG": ("urlaub", "Dienstrecht", 3),
"VKG": ("karenz", "Familienrecht", 1),
"ZDG": ("prasenzdienst", "Zivildienst", 1),
}
DATE_RE = re.compile(r"(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})")
YEAR_RE = re.compile(r"(20\d\d)")
def ascii_slug(text: str) -> str:
"""Wissensbasis-Konvention: Umlaute auf Basisbuchstabe (ü→u, nicht ue),
ß→ss, Rest klein; alles außer a-z0-9 und Bindestrich fällt weg."""
repl = {"ä": "a", "ö": "o", "ü": "u", "Ä": "a", "Ö": "o", "Ü": "u", "ß": "ss"}
for k, v in repl.items():
text = text.replace(k, v)
return re.sub(r"[^a-z0-9-]+", "-", text.lower()).strip("-")
def stand_from_date(date_match: tuple[str, str, str]) -> str:
d, m, y = date_match
return f"{y}-{int(m):02d}"