Files
planer2/backend/struktur.py
2026-09-07 07:44:00 +02:00

204 lines
8.9 KiB
Python

"""Stufe 1 der Prüfung: alles, was sich zählen lässt, zählt der Code.
Das Modell erfand hier Regeln, die im Katalog nicht stehen — „Im" als Füllwort,
Satzzeichen, wo keins ist. Diese Klasse ist jetzt abschließend hier erledigt, und
der Prompt verbietet dem Modell, sie noch einmal zu melden.
Ein Befund mit Vorschlag geht ohne Anfrage durch. Ein Befund ohne Vorschlag heißt:
der Code weiß, dass die Stelle falsch ist, aber nicht, was richtig wäre — den
Auftrag bekommt das Modell.
"""
import re
# Höchstzahl Wörter je Zeile, nach (bereich, feld)
WORTGRENZEN = {
("feature", "titel"): 3,
("funktion", "titel"): 5,
("feature", "regeln"): 10,
("funktion", "regeln"): 10,
("funktion", "testen"): 8,
}
ZIEL_SAETZE = 2
ZIEL_WOERTER = 12
# Abschließend. Genau diese Wörter, keine sinnverwandten.
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
BADGE = ("kern", "optional")
# Felder, die als Liste geführt werden — je Zeile ein Eintrag.
# „funktionen" gehört der Inhaltsprüfung, hat aber dieselbe Gestalt.
ZEILENFELDER = ("regeln", "testen", "funktionen")
# Ein Funktionstitel nennt eine Handlung. Ohne Verb am Ende steht dort ein Bereich
# („Galerie im Modal") oder ein Zustand („Preis aktualisiert") — beides beschreibt
# nichts, was man für sich testen könnte. Deutsche Infinitive enden auf -en, -ln,
# -rn; wie Füllwörter und Wortgrenzen ist auch das an die Sprache gebunden.
_INFINITIV = re.compile(r"(?:e[lr]?n|ln|rn)$", re.I)
# Eine Handlung steht als Infinitiv am Anfang oder am Ende der Zeile („Bestätigen im
# Modal", „Text ändern") oder beginnt mit einer Taste. Alles andere ist ein Ergebnis.
# Nur Anfang und Ende, nicht irgendwo: deutsche Mehrzahl endet auch auf -en.
# „Befehl" und „Kommando" stehen dabei wie eine Taste: auf der Kommandozeile ist der
# Aufruf die Handlung, und ohne sie wäre ein Setup nicht bedienbar zu beschreiben.
_TASTE = re.compile(
r"^(?:LMB|RMB|MMB|Klick|Doppelklick|Enter|Escape|Strg|Tab|Befehl|Kommando)\b", re.I)
SATZENDE = ".;"
_KLAMMER = re.compile(r"[()/]")
# Ein Zitat ist eine Einheit: ein Menüeintrag oder ein Kommando. „Befehl "make install"
# ausführen" sind drei Wörter, nicht vier, und der Schrägstrich in „npm run build/dev"
# ist keiner von uns. Darum wird außerhalb der Zitate gemessen.
_ZITAT = re.compile(r'"[^"]*"|„[^“]*“')
# Eine Vorlage ist kein Inhalt. Das Modell schlug `<testschritt>` als Testschritt
# vor — formal richtig, gemeint war „hier fehlt noch etwas".
_LUECKE = re.compile(r"<[^<>]*>|\.\.\.|\bTODO\b|\bXXX\b", re.I)
_STRICH = re.compile(r"^[-*]\s*")
def _zeilen(feld: str, text: str) -> list[str]:
"""regeln und testen sind Listen, alles andere ist ein Block."""
if feld in ZEILENFELDER:
return [_STRICH.sub("", z.strip()) for z in text.splitlines() if z.strip()]
return [text.strip()] if text.strip() else []
def _problem(feld: str, original: str, problem: str, vorschlag: str = "") -> dict:
return {"feld": feld, "original": original, "problem": problem, "vorschlag": vorschlag}
def _messbar(zeile: str) -> str:
"""Die Zeile mit jedem Zitat als einem Wort — so wird gezählt und gesucht."""
return _ZITAT.sub("Zitat", zeile)
def _ausserhalb(zeile: str, wandel) -> str:
"""Wendet `wandel` nur auf die Teile außerhalb der Zitate an.
Ein Füllwort im Menüeintrag ist keins — dort steht der Text, den die Anwendung
zeigt. Würde er mitgestrichen, verwiese der Testschritt auf einen Knopf, den es
nicht gibt.
"""
teile, letzt = [], 0
for m in _ZITAT.finditer(zeile):
teile.append(wandel(zeile[letzt:m.start()]))
teile.append(m.group(0))
letzt = m.end()
teile.append(wandel(zeile[letzt:]))
return "".join(teile)
def _ohne_wort(zeile: str, wort: str) -> str:
weg = lambda t: re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", t, flags=re.I)
return re.sub(r"\s{2,}", " ", _ausserhalb(zeile, weg)).strip()
def _reparierbar(zeile: str) -> tuple[list[str], str]:
"""Verstöße, die sich wegstreichen lassen, samt der bereinigten Zeile."""
gruende, neu = [], zeile
if neu[-1] in SATZENDE:
gruende.append("Satzzeichen am Zeilenende")
neu = neu[:-1].rstrip()
for wort in FUELLWOERTER:
if re.search(rf"\b{wort}\b", _messbar(neu), re.I):
gruende.append(f"Füllwort „{wort}“")
neu = _ohne_wort(neu, wort)
return gruende, neu
def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
"""Verstöße, die der Code sieht, aber nicht beheben kann."""
gruende = []
gemessen = _messbar(zeile)
if _KLAMMER.search(gemessen):
gruende.append("Klammer oder Schrägstrich")
if _LUECKE.search(gemessen):
gruende.append("Platzhalter statt Inhalt")
grenze = WORTGRENZEN.get((bereich, feld))
if grenze and len(gemessen.split()) > grenze:
gruende.append(f"Mehr als {grenze} Wörter")
if bereich == "funktion" and feld == "titel":
woerter = gemessen.split()
if woerter and not _INFINITIV.search(woerter[-1]):
gruende.append("Kein Verb im Infinitiv am Ende")
# Der Titel nennt die Wirkung, nicht den Griff. „Befehl "make install"
# ausführen" ist ein Testschritt — als Titel sagt er nicht, was danach gilt.
if _TASTE.match(zeile):
gruende.append("Auslöser statt Wirkung im Titel")
if feld == "ziel":
saetze = [s.strip() for s in re.split(r"[.!?]+", gemessen) if s.strip()]
if len(saetze) > ZIEL_SAETZE:
gruende.append(f"Mehr als {ZIEL_SAETZE} Sätze")
if any(len(s.split()) > ZIEL_WOERTER for s in saetze):
gruende.append(f"Satz mit mehr als {ZIEL_WOERTER} Wörtern")
return gruende
def _handlung(zeile: str) -> bool:
woerter = _messbar(zeile).split()
if not woerter:
return True
return bool(_TASTE.match(zeile) or _INFINITIV.search(woerter[-1])
or _INFINITIV.search(woerter[0]))
def _zweiter_ablauf(zeilen: list[str]) -> str:
"""Die Zeile, ab der ein zweiter Durchlauf beginnt — sonst leer.
Ein Ablauf ist: handeln, handeln, Ergebnis. Steht nach dem Ergebnis wieder eine
Handlung, sind es zwei Abläufe — und damit zwei Funktionen. Genau so entstand
„Bildergalerie bedienen" mit blättern, Miniatur wählen und Großansicht in einem.
"""
ergebnis = False
for i, zeile in enumerate(zeilen):
if ergebnis and _handlung(zeile):
return zeile
# Die erste Zeile darf ein Zustand sein: das ist die Vorbedingung, nicht das
# Ergebnis. „Badge ist sichtbar" vor „RMB auf Badge" ist ein Ablauf, nicht zwei.
if i and not _handlung(zeile):
ergebnis = True
return ""
def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
"""Höchstens ein Befund je Zeile. Zwei Vorschläge für dieselbe Zeile wären nicht
unabhängig — der zweite fände seine Stelle nach dem ersten nicht mehr."""
gruende, neu = _reparierbar(zeile)
offen = _umschreiben(bereich, feld, zeile)
if offen:
# Muss ohnehin neu geschrieben werden — dann alles in einem Zug, vom Modell
return [_problem(feld, zeile, ", ".join(gruende + offen))]
return [_problem(feld, zeile, ", ".join(gruende), neu)] if gruende else []
def befunde(bereich: str, felder: dict) -> list[dict]:
"""Alle messbaren Verstöße einer Prüfstelle, in Reihenfolge der Felder."""
raus = []
# Testschritte sind Pflicht. Eine Funktion ist definiert als etwas, das man für
# sich testen kann — ohne Ablauf ist die Behauptung unbelegt und der Titel lässt
# sich über alles stülpen. Ziel und Regeln bleiben freiwillig.
# Nur wenn die ganze Funktion vorliegt: bei einem einzelnen Feld sagt das Fehlen
# der anderen nichts.
inhaltsfelder = ("ziel", "regeln", "testen")
if bereich == "funktion" and all(f in felder for f in inhaltsfelder) \
and not (felder.get("testen") or "").strip():
raus.append(_problem("testen", "", "Funktion ohne Testschritte"))
return raus
for feld, text in felder.items():
if feld == "badge":
if text and text not in BADGE:
raus.append(_problem(feld, text, "Weder kern noch optional", "kern"))
continue
zeilen = _zeilen(feld, text or "")
for zeile in zeilen:
raus += _zeile(bereich, feld, zeile)
if feld == "testen" and not raus:
# Erst prüfen, wenn die Zeilen für sich in Ordnung sind: sonst hängen
# zwei Vorschläge am selben Feld und der zweite findet seine Stelle nicht.
if bruch := _zweiter_ablauf(zeilen):
raus.append(_problem(feld, bruch, "Zweiter Ablauf in einer Funktion"))
return raus
def auftrag(befund: dict) -> str:
"""Zeile für das Modell: der Code hat gemessen, den Ersatz schreibt das Modell."""
return f'- {befund["feld"]} „{befund["original"]}“: {befund["problem"]}'