Eingangsordner input/ und Skill sn-einlesen (#5)

- input/ ist lokal und nicht versioniert (.gitignore); scripts/einlesen.py
  legt ihn bei Bedarf an.
- scripts/einlesen.py ordnet jede Datei ein: abgelehnt (kein .md, nicht
  UTF-8, leer, Unterordner), neu, geändert, eingelesen. Für neue und
  geänderte Dateien: Prozessname, Ordnernamen-Vorschlag, ob schon eine
  Arbeitsschritt-.md, und ähnliche Prozesse aus prozesse/ und entwuerfe/.
  „vermerk“ schreibt nach Erfolg eine erste Zeile
  <!-- eingelesen: <Zeitstempel> | neu|aktualisiert | <Ziel> | inhalt: <sha> -->;
  die Prüfsumme erkennt spätere Änderungen. Zeilenenden bleiben erhalten.
- Skill sn-einlesen: Übersicht, Bestandsvergleich, Entscheidung des
  Nutzers, neue Prozesse durch sn-bpmn, Vermerk nur bei Erfolg.
- docs/leitfaden/07_prozesse_einlesen.md: Ablauf für neue Nutzer.
- AGENTS.md, README, Leitfaden 03, Skill sn-bpmn, Fallstricke, STATUS.
- Tests: python -m pytest -q -> 79 bestanden (18 neu in
  tests/test_einlesen.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:09:48 +02:00
co-authored by Claude Opus 5.5
parent 47b59e2d83
commit 27589749ff
13 changed files with 769 additions and 7 deletions
+281
View File
@@ -0,0 +1,281 @@
#!/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 (prozesse/, 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
ROOT = Path(__file__).resolve().parents[1]
EINGANG = ROOT / "input"
PROZESSE = ROOT / "prozesse"
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 0003: 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 (prozesse/) und lokale Entwürfe (entwuerfe/) mit Titel und Wörtern."""
eintraege = []
if PROZESSE.is_dir():
for ordner in sorted(p for p in PROZESSE.iterdir() if p.is_dir()):
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": ordner.relative_to(ROOT).as_posix() + "/", "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 prozesse/<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())