Files
Projektwissen/scripts/einlesen.py
T
DanielandClaude Opus 5.5 31131cc5c3 Prozessbibliothek als eigenes Repository auslagern (#9)
- ADR 0004 ersetzt ADR 0003: Die Prozessmodelle liegen im Repository
  AG_Prozesse/Prozessbibliothek, geklont nach Prozessbibliothek/ (neuer
  Eintrag in workspace.toml, .gitignore). prozesse/ ist entfernt; das
  Ablageformat (Ordner je Prozess, drei Dateien, Status) bleibt.
- pruefe_prozesse.py prüft den Klon (Ort über setup_workspace.repos_root),
  lässt Punkt-Ordner wie .git aus und erlaubt in der Wurzel nur die
  Repo-Dateien. Fehlt der Klon, gibt es einen Hinweis statt eines Befunds;
  pytest warnt dann.
- einlesen.py sucht den Bestand in der Bibliothek und nennt Treffer als
  Prozessbibliothek/<name>/, auch wenn der Klon woanders liegt.
- Regeln aus prozesse/README.md nach docs/leitfaden/08_prozessbibliothek.md,
  inklusive Ablauf bei Konverteränderungen (zwei Pull Requests).
- Verweise in AGENTS.md, README, Skills, Leitfäden, Vorlagen, Fallstricken
  und STATUS nachgezogen.
- Tests: python -m pytest -q -> 88 bestanden, 1 xfail (#8), 1 Warnung
  (Bibliothek noch nicht geklont).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:40:52 +02:00

285 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Prüft den Eingangsordner input/ für den Skill sn-einlesen und setzt Einlese-Vermerke.
Der Ordner input/ ist nicht versioniert. Dort legen Fachleute Prozessbeschreibungen als .md ab
(Rohtext, Stichpunkte oder schon eine Arbeitsschritt-.md). Dieses Skript erledigt nur das
Mechanische; die fachliche Einschätzung und die Rückfrage an den Menschen macht der Skill.
pruefen (Standard) Jede Datei einordnen:
abgelehnt kein .md, keine UTF-8-Datei oder Unterordner – wird nicht bearbeitet
neu .md ohne Einlese-Vermerk
geändert .md mit Vermerk, aber der Inhalt hat sich seitdem geändert
eingelesen .md mit Vermerk, Inhalt unverändert – wird übersprungen
Für neue und geänderte Dateien: vorgeschlagener Prozessname, ob die Datei schon eine
gültige Arbeitsschritt-.md ist, und ähnliche vorhandene Prozesse
(Prozessbibliothek, entwuerfe/).
vermerk Trägt nach erfolgreichem Einlesen oben in die .md einen Vermerk mit Zeitstempel ein.
Aufrufe (aus dem Workspace-Root):
python scripts/einlesen.py # Übersicht
python scripts/einlesen.py --json # dasselbe maschinenlesbar (für den Agenten)
python scripts/einlesen.py vermerk input/antrag.md --ergebnis neu --ziel entwuerfe/antrag-pruefen.md
Der Vermerk ist ein HTML-Kommentar in der ersten Zeile, z. B.
<!-- eingelesen: 2026-09-23 14:05:12 +0200 | neu | entwuerfe/antrag-pruefen.md | inhalt: 3f2a9c1e0b7d -->
Er ist in der Markdown-Ansicht unsichtbar und stört md_to_bpmn.py nicht (steht vor dem ersten Schritt).
„inhalt“ ist der Anfang der SHA-256 des Dateiinhalts ohne Vermerke; so fällt eine spätere Änderung auf.
Exit-Code: 0 = in Ordnung, 1 = Fehler beim Vermerk (Datei fehlt, kein .md, …), 2 = Aufruffehler.
"""
from __future__ import annotations
import argparse
import difflib
import hashlib
import json
import re
import sys
import unicodedata
from datetime import datetime
from pathlib import Path
from setup_workspace import repos_root
ROOT = Path(__file__).resolve().parents[1]
EINGANG = ROOT / "input"
BIBLIOTHEK = repos_root() / "Prozessbibliothek" # eigenes Repository (ADR 0004), wie in pruefe_prozesse.py
ENTWUERFE = ROOT / "entwuerfe"
SKILL = ROOT / ".agents" / "skills" / "sn-bpmn"
sys.path.insert(0, str(SKILL))
import md_to_bpmn # noqa: E402 (liegt im Skill-Ordner, nicht als Paket installiert)
ERGEBNISSE = ("neu", "aktualisiert")
VERMERK = re.compile(
r"^<!-- eingelesen: (?P<zeit>[^|]+?) \| (?P<ergebnis>[^|]+?) \| (?P<ziel>[^|]+?) \| inhalt: (?P<hash>[0-9a-f]+) -->$"
)
# Von Windows oder Werkzeugen angelegte Dateien, die niemand bewusst in input/ legt
STILL_IGNORIERT = {"desktop.ini", "thumbs.db"}
FUELLWOERTER = {
"aber", "alle", "auch", "beim", "bzw", "dann", "dass", "diese", "dieser", "durch", "eine", "einem", "einen",
"einer", "eines", "falls", "für", "hier", "immer", "jede", "jeder", "kann", "keine", "mehr", "muss", "nach",
"noch", "oder", "ohne", "sich", "sind", "soll", "sowie", "über", "unter", "wenn", "werden", "wird", "zwei",
# Wörter der Arbeitsschritt-Syntax sagen nichts über den Inhalt
"prozess", "start", "ende", "task", "aufgabe", "gateway", "extern", "hinweis", "sendeaufgabe",
"empfangsaufgabe", "zwischenereignis", "nachricht", "beispiel",
}
SCHWELLE = 0.25 # ab dieser Ähnlichkeit wird ein vorhandener Prozess als Kandidat genannt
MAX_KANDIDATEN = 3
# ------------------------------------------------------------------ Vermerk ---
def ohne_vermerke(text: str) -> str:
return "\n".join(z for z in text.splitlines() if not VERMERK.match(z.strip()))
def inhalt_hash(text: str) -> str:
"""Kurz-Hash des Inhalts ohne Vermerke und ohne Unterschiede bei Zeilenenden/Leerzeilen am Ende."""
kern = ohne_vermerke(text.replace("\r\n", "\n")).strip()
return hashlib.sha256(kern.encode("utf-8")).hexdigest()[:12]
def letzter_vermerk(text: str) -> dict | None:
"""Der oberste (= neueste) Vermerk der Datei oder None."""
for zeile in text.splitlines():
treffer = VERMERK.match(zeile.strip())
if treffer:
return treffer.groupdict()
return None
def setze_vermerk(pfad: Path, ergebnis: str, ziel: str, jetzt: datetime | None = None) -> str:
"""Schreibt den Vermerk als erste Zeile; ältere Vermerke bleiben darunter stehen. Liefert die Zeile."""
if ergebnis not in ERGEBNISSE:
raise ValueError(f"Ergebnis '{ergebnis}' unbekannt, erlaubt: {', '.join(ERGEBNISSE)}")
# Als Bytes lesen: read_text() würde CRLF stillschweigend zu LF machen und die Datei so umschreiben.
text = pfad.read_bytes().decode("utf-8-sig")
zeit =(jetzt or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M:%S %z")
zeile = f"<!-- eingelesen: {zeit} | {ergebnis} | {ziel} | inhalt: {inhalt_hash(text)} -->"
zeilenende = "\r\n" if "\r\n" in text else "\n"
pfad.write_text(zeile + zeilenende + text, encoding="utf-8", newline="")
return zeile
# ------------------------------------------------------------ Einordnung ---
def slug(text: str) -> str:
"""Ordnername nach ADR 0004: Kleinbuchstaben, Ziffern, Bindestriche; Umlaute ausgeschrieben."""
text = text.lower()
for alt, neu in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
text = text.replace(alt, neu)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")
def titel_von(text: str, ersatz: str) -> str:
"""Prozessname aus 'Prozess:'-Zeile, sonst erster Überschrift, sonst dem Dateinamen."""
for zeile in ohne_vermerke(text).splitlines():
treffer = re.match(r"^\s*(?:prozess|prozessname)\s*:\s*(.+)$", zeile, re.IGNORECASE)
if treffer:
return treffer.group(1).strip()
for zeile in ohne_vermerke(text).splitlines():
treffer = re.match(r"^\s*#{1,3}\s+(.+)$", zeile)
if treffer:
return treffer.group(1).strip()
return ersatz
def woerter(text: str) -> set[str]:
return {w for w in re.findall(r"[a-zäöüß]{4,}", text.lower()) if w not in FUELLWOERTER}
def ist_arbeitsschritt_md(text: str) -> bool:
"""True, wenn md_to_bpmn.py die Datei ohne Fehler liest und mindestens einen Schritt findet."""
try:
_, _, order, _ = md_to_bpmn.parse_markdown(text)
except ValueError:
return False
return bool(order)
def bestand() -> list[dict]:
"""Vorhandene Prozesse (Prozessbibliothek) und lokale Entwürfe (entwuerfe/) mit Titel und Wörtern."""
eintraege = []
if BIBLIOTHEK.is_dir():
for ordner in sorted(p for p in BIBLIOTHEK.iterdir() if p.is_dir() and not p.name.startswith(".")):
md = ordner / f"{ordner.name}.md"
text = md.read_text(encoding="utf-8") if md.is_file() else ""
readme = ordner / "README.md"
titel = titel_von(readme.read_text(encoding="utf-8"), ordner.name) if readme.is_file() else ordner.name
eintraege.append({"art": "Prozess", "name": ordner.name, "titel": titel_von(text, titel),
"pfad": f"{BIBLIOTHEK.name}/{ordner.name}/", "woerter": woerter(text)})
if ENTWUERFE.is_dir():
for md in sorted(ENTWUERFE.glob("*.md")):
if re.match(r"^\d{8}-\d{6}-(issue|kommentar)-", md.name):
continue # Gitea-Entwürfe aus scripts/gitea.py
text = md.read_text(encoding="utf-8", errors="replace")
eintraege.append({"art": "Entwurf (lokal)", "name": md.stem, "titel": titel_von(text, md.stem),
"pfad": md.relative_to(ROOT).as_posix(), "woerter": woerter(text)})
return eintraege
def aehnlichkeit(titel: str, text: str, kandidat: dict) -> float:
"""0 … 1: halb Namensähnlichkeit, halb Wortüberschneidung. Gleicher Ordnername ergibt 1."""
eigener = slug(titel)
if eigener and eigener in (kandidat["name"], slug(kandidat["titel"])):
return 1.0
name = max(difflib.SequenceMatcher(None, eigener, slug(t)).ratio()
for t in (kandidat["name"], kandidat["titel"]))
a, b = woerter(text), kandidat["woerter"]
ueberschneidung = len(a & b) / len(a | b) if a and b else 0.0
return round(0.5 * name + 0.5 * ueberschneidung, 2)
def ordne_ein(pfad: Path, vorhanden: list[dict]) -> dict:
eintrag = {"datei": pfad.relative_to(ROOT).as_posix()}
if pfad.is_dir():
return eintrag | {"status": "abgelehnt", "grund": "Unterordner werden nicht gelesen"}
if pfad.suffix.lower() != ".md":
return eintrag | {"status": "abgelehnt",
"grund": f"Dateityp {pfad.suffix or '(ohne Endung)'} – vorerst nur .md"}
try:
text = pfad.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
return eintrag | {"status": "abgelehnt", "grund": "keine UTF-8-Datei – im Editor als UTF-8 speichern"}
if not ohne_vermerke(text).strip():
return eintrag | {"status": "abgelehnt", "grund": "Datei ist leer"}
vermerk = letzter_vermerk(text)
if vermerk and vermerk["hash"] == inhalt_hash(text):
return eintrag | {"status": "eingelesen", "vermerk": vermerk}
titel = titel_von(text, pfad.stem)
kandidaten = sorted(
({"art": k["art"], "name": k["name"], "titel": k["titel"], "pfad": k["pfad"],
"aehnlichkeit": aehnlichkeit(titel, text, k)} for k in vorhanden),
key=lambda k: -k["aehnlichkeit"],
)
return eintrag | {
"status": "geändert" if vermerk else "neu",
"vermerk": vermerk,
"titel": titel,
"vorschlag_name": slug(titel) or slug(pfad.stem),
"arbeitsschritt_md": ist_arbeitsschritt_md(ohne_vermerke(text)),
"kandidaten": [k for k in kandidaten if k["aehnlichkeit"] >= SCHWELLE][:MAX_KANDIDATEN],
}
def pruefe_eingang(eingang: Path | None = None) -> dict:
"""Ordnet alle Einträge in input/ ein; legt den Ordner an, falls er fehlt."""
eingang = eingang or EINGANG
angelegt = not eingang.is_dir()
eingang.mkdir(exist_ok=True)
vorhanden = bestand()
dateien = [ordne_ein(p, vorhanden) for p in sorted(eingang.iterdir())
if not p.name.startswith(".") and p.name.lower() not in STILL_IGNORIERT]
return {
"eingang": eingang.relative_to(ROOT).as_posix() + "/",
"angelegt": angelegt,
"dateien": dateien,
"bestand": [{k: v for k, v in e.items() if k != "woerter"} for e in vorhanden],
}
# ---------------------------------------------------------------- Ausgabe ---
def als_text(bericht: dict) -> str:
zeilen = []
if bericht["angelegt"]:
zeilen.append(f"Ordner {bericht['eingang']} war nicht vorhanden und wurde angelegt.")
if not bericht["dateien"]:
zeilen.append(f"{bericht['eingang']} ist leer – nichts einzulesen.")
for d in bericht["dateien"]:
status = d["status"]
if status == "abgelehnt":
zeilen.append(f"{'[abgelehnt]':<13}{d['datei']}: {d['grund']}")
elif status == "eingelesen":
v = d["vermerk"]
zeilen.append(f"{'[eingelesen]':<13}{d['datei']}: am {v['zeit']} ({v['ergebnis']} -> {v['ziel']})")
else:
art = "Arbeitsschritt-.md" if d["arbeitsschritt_md"] else "Rohtext"
zeilen.append(f"{'[' + status + ']':<13}{d['datei']}: „{d['titel']}“ ({art}), "
f"Vorschlag: {d['vorschlag_name']}")
for k in d["kandidaten"]:
zeilen.append(f" ähnlich ({k['aehnlichkeit']:.2f}): {k['art']} {k['pfad']} – „{k['titel']}“")
if not d["kandidaten"]:
zeilen.append(" kein ähnlicher Prozess im Bestand")
zaehler = {s: sum(1 for d in bericht["dateien"] if d["status"] == s)
for s in ("neu", "geändert", "eingelesen", "abgelehnt")}
zeilen.append("\n" + ", ".join(f"{n} {s}" for s, n in zaehler.items()) +
f" | Bestand: {len(bericht['bestand'])} Prozess(e)/Entwurf(e)")
return "\n".join(zeilen)
def main() -> int:
for strom in (sys.stdout, sys.stderr):
if hasattr(strom, "reconfigure"):
strom.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--json", action="store_true", help="Übersicht als JSON")
unter = ap.add_subparsers(dest="befehl")
v = unter.add_parser("vermerk", help="Einlese-Vermerk in eine .md schreiben")
v.add_argument("datei", help="Datei in input/")
v.add_argument("--ergebnis", required=True, choices=ERGEBNISSE)
v.add_argument("--ziel", required=True, help="wo das Ergebnis liegt, z. B. entwuerfe/<name>.md oder Prozessbibliothek/<name>/")
args = ap.parse_args()
if args.befehl == "vermerk":
pfad = Path(args.datei)
if not pfad.is_file() or pfad.suffix.lower() != ".md":
print(f"Keine .md-Datei: {pfad}")
return 1
print(setze_vermerk(pfad, args.ergebnis, args.ziel))
return 0
bericht = pruefe_eingang()
print(json.dumps(bericht, ensure_ascii=False, indent=2) if args.json else als_text(bericht))
return 0
if __name__ == "__main__":
raise SystemExit(main())