Files
planer2/backend/struktur.py
2026-07-29 15:19:41 +02:00

168 lines
7.1 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.
_TASTE = re.compile(r"^(?:LMB|RMB|MMB|Klick|Doppelklick|Enter|Escape|Strg|Tab)\b", re.I)
SATZENDE = ".;"
_KLAMMER = 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 _ohne_wort(zeile: str, wort: str) -> str:
neu = re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", zeile, flags=re.I).strip()
return re.sub(r"\s{2,}", " ", neu)
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", 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 = []
if _KLAMMER.search(zeile):
gruende.append("Klammer oder Schrägstrich")
if _LUECKE.search(zeile):
gruende.append("Platzhalter statt Inhalt")
grenze = WORTGRENZEN.get((bereich, feld))
if grenze and len(zeile.split()) > grenze:
gruende.append(f"Mehr als {grenze} Wörter")
if bereich == "funktion" and feld == "titel":
woerter = zeile.split()
if woerter and not _INFINITIV.search(woerter[-1]):
gruende.append("Kein Verb im Infinitiv am Ende")
if feld == "ziel":
saetze = [s.strip() for s in re.split(r"[.!?]+", zeile) 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 = 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 zeile in zeilen:
if ergebnis and _handlung(zeile):
return zeile
if 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 = []
# Ein bloßer Titel beschreibt nichts — und lässt sich über alles stülpen. Der
# Prüfer hielt damit einen Druckknopf für „Modal mit Tastatur steuern".
# 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 any((felder.get(f) or "").strip() for f in inhaltsfelder):
raus.append(_problem("testen", "", "Funktion ohne Ziel, Regeln und Testen"))
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"]}'