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:
+18
-5
@@ -58,11 +58,13 @@ zu planen).
|
||||
|
||||
- **Push**: Remote localhost:3003 aus der Sandbox nicht erreichbar — User
|
||||
pusht vom Host.
|
||||
- **Fehlverweigerungen**: q-008 (Abfertigung/Verfügungsmöglichkeiten),
|
||||
q-031 (Mindestlohngesetz) — breite Fragen, Retrieval erfolgreich, Modell
|
||||
verweigert. M3-Tuning: Regel-4-Formulierung lockern („behandle den
|
||||
behandelten Teil“) oder Kontextblöcke erhöhen. Vorher Prompt einfrieren
|
||||
für den Bake-off-Vergleich.
|
||||
- **Fehlverweigerungen — Stand nach Prompt v2:** q-008 und q-031 sind
|
||||
**behoben** (Section-Priorität + Regel 8). Verbleibend: q-029
|
||||
(„Welche Neuerungen behandelt WIKU Personal aktuell 2026?“) — breite
|
||||
Survey-Frage über 12 Hefte; Kontext enthält echte Zusammenfassungen,
|
||||
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).
|
||||
Hebel: weniger Kontextblöcke (aktuell 8+6), schnellere Kandidaten.
|
||||
- **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
|
||||
dokumentierter Latenz-Kandidat; ein Modellwechsel läuft nur erneut über
|
||||
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
|
||||
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
||||
korrektheit > Latenz.
|
||||
|
||||
+9
-3
@@ -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).
|
||||
|
||||
**Antworten** (Bake-off-Sieger qwen3.8:27b, Thinking aus, Temperatur 0,1):
|
||||
**Zitier-Präzision 100 %** (4 Zitierverletzungen wurden von der
|
||||
Post-Validierung abgefangen und regeneriert) · Verweigerung korrekt
|
||||
94,3 % · erwartete Quelle zitiert 80,6 % · Latenz mean 32 s / p95 53 s.
|
||||
**Zitier-Präzision 100 %** · Verweigerung korrekt 94,3 % · erwartete Quelle
|
||||
zitiert **83,9 %** · Latenz mean 34 s / p95 54 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**
|
||||
(Protokoll: Zitier-Präzision → Verweigerungskorrektheit → Latenz):
|
||||
|
||||
@@ -99,6 +99,10 @@ questions:
|
||||
- id: q-031
|
||||
question: "Was regelt das Mindestlohngesetz und für wen gilt es?"
|
||||
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) ---
|
||||
- id: r-001
|
||||
|
||||
+10
-1
@@ -33,12 +33,21 @@ Verbindliche Regeln:
|
||||
IDs, die nur im Fließtext als Verweis genannt werden, sind Querverweise
|
||||
und KEINE Belege.
|
||||
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.
|
||||
5. Widersprechen sich Kontextblöcke, nenne beide Werte mit ihren IDs und
|
||||
kennzeichne den Widerspruch mit ⚠. Löse Widersprüche niemals stillschweigend auf.
|
||||
6. Nenne Paragraphen und Gesetze nur, wenn ein Kontextblock sie nennt.
|
||||
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."""
|
||||
|
||||
|
||||
+53
-5
@@ -34,6 +34,29 @@ class ChunkResult:
|
||||
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:
|
||||
def __init__(self, cfg: Config, db_path: str | None = None, client=None):
|
||||
self.cfg = cfg
|
||||
@@ -182,6 +205,28 @@ class Retriever:
|
||||
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:
|
||||
rows = self._con.execute(
|
||||
"SELECT * FROM chunks WHERE entry_id = ? "
|
||||
@@ -224,16 +269,19 @@ class Retriever:
|
||||
results.append(self._row_to_result(rows[cid], score, source))
|
||||
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] = []
|
||||
seen: set[str] = set()
|
||||
by_entry: dict[str, list[ChunkResult]] = {}
|
||||
for r in results:
|
||||
if r.entry_id in seen:
|
||||
continue
|
||||
seen.add(r.entry_id)
|
||||
main.append(r)
|
||||
by_entry.setdefault(r.entry_id, []).append(r)
|
||||
for entry_id, chunks in by_entry.items():
|
||||
if len(main) >= n:
|
||||
break
|
||||
seen.add(entry_id)
|
||||
main.append(self._representative_chunk(entry_id, chunks))
|
||||
|
||||
# cross_ref-Erweiterung (kontrolliert, markiert, begrenzt)
|
||||
extra: list[ChunkResult] = []
|
||||
|
||||
+10
@@ -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
|
||||
in keiner Kennzahl. q-008 und q-031 verweigern alle Top-Kandidaten —
|
||||
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
|
||||
Odoo-19-LLM-Module).
|
||||
- Betrieb: `agent/README.md`.
|
||||
|
||||
@@ -4,3 +4,4 @@ httpx>=0.27
|
||||
PyYAML>=6.0
|
||||
numpy>=2.0
|
||||
pytest>=8.0
|
||||
beautifulsoup4>=4.12
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user