This commit is contained in:
team3
2026-07-29 15:19:41 +02:00
parent 2e86d682eb
commit 5d42b4cfc5
155 changed files with 14482 additions and 1736 deletions

View File

@@ -14,8 +14,10 @@ API_VERSION = "2023-06-01"
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
WIEDERHOLBAR = (429, 500, 502, 503, 504)
WIEDERHOLUNGEN = 4
WIEDERHOLUNGEN = 6
PAUSE = 2.0
# Länger als das zu warten hilft nicht mehr; dann ist die Drossel zu weit offen
HOECHSTPAUSE = 60.0
class NichtKonfiguriert(ValueError):
@@ -37,6 +39,15 @@ def pruefe_konfiguration():
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
def _pause(res, versuch: int) -> float:
"""Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen."""
gesagt = res.headers.get("retry-after")
try:
return min(float(gesagt), HOECHSTPAUSE)
except (TypeError, ValueError):
return min(PAUSE * (2 ** versuch), HOECHSTPAUSE)
async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
"""Eine Anfrage, eine Antwort — der Text der Modellantwort.
Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten.
@@ -63,7 +74,7 @@ async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
# Zu viele Anfragen oder Serverfehler: warten und noch einmal.
# Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein
# fehlendes Urteil sah aus wie „nichts gefunden".
await asyncio.sleep(PAUSE * (2 ** versuch))
await asyncio.sleep(_pause(res, versuch))
if res.status_code != 200:
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
antwort = res.json()

292
backend/chat.py Normal file
View File

@@ -0,0 +1,292 @@
"""Karten im Gespräch entwickeln.
Man schreibt, was man vorhat. Die KI antwortet und legt daneben, wie die Karten
danach aussähen. Übernommen wird einzeln — dasselbe ✓ und ✕ wie bei der Prüfung.
Warum kein eigener Anzeigeweg: Ein Vorschlag ist ein Vorschlag, egal ob ihn die
Prüfung oder das Gespräch gemacht hat. Der Chat liefert deshalb genau die Form, die
`pruefung._stellen` erzeugt — Karten zeigen ihn an derselben Stelle an, der Vergleich
funktioniert, Annehmen schreibt die Datei.
Das Modell schreibt nie selbst in eine Datei. Es beschreibt den Sollzustand einer
Karte; was sich dadurch ändert, rechnet der Code aus. Das ist dieselbe Beweislast wie
bei der Prüfung und der Grund, warum ein Missverständnis hier nichts kaputt macht.
"""
import json
import agents
import features
import parser
import projekte
import pruefung
DATEI = ".chat.json"
# So viele Wortmeldungen bleiben stehen. Mehr wäre teuer je Anfrage und hilft wenig:
# was vor zwanzig Schritten besprochen wurde, steht längst auf den Karten.
TIEFE = 30
# Antworten in einem Rutsch. Ein Gespräch verträgt keine sechs Stimmen — es soll
# schnell sein, und was falsch ist, sieht man am Vorschlag.
TEMPERATUR = 0.3
SYSTEM = """Du entwickelst mit einer Person zusammen die Feature-Karten eines Projekts.
Eine Karte ist ein Auftrag: die Person will ein Problem gelöst haben. Die Lösung
besteht aus Regeln und Funktionen. Eine Funktion ist etwas, das man für sich testen
kann. Was nur die Umsetzung betrifft, gehört auf keine Karte.
{stil}
Der aktuelle Stand:
{karten}
Antworte ausschließlich mit JSON:
{{"antwort": "...", "karten": [
{{"titel": "...", "art": "kern", "ziel": "...", "regeln": ["..."],
"funktionen": [{{"titel": "...", "ziel": "...", "regeln": ["..."],
"testen": ["..."]}}]}}
]}}
- antwort: was du der Person sagst, auf Deutsch, höchstens vier Sätze
- karten: nur die Karten, die sich ändern oder dazukommen sollen — jeweils
VOLLSTÄNDIG, so wie sie danach aussehen sollen. Der Stand oben hat dieselbe Form:
kopiere die Karte von dort und ändere nur, was sich ändern soll. Jede Zeile, die
du nicht wörtlich mit abschreibst, gilt als gestrichen.
- art: „kern", wenn die Anwendung ohne die Karte keinen Sinn hätte, sonst „optional"
- ziel, regeln und testen dürfen leer sein oder fehlen
Ist nichts zu ändern, ist karten leer. Ändere nur, wonach gefragt wurde; Karten,
die niemand erwähnt hat, lässt du weg.
Frag nach, was du nicht weißt — aber leg trotzdem einen Entwurf daneben. Eine Frage
ohne Vorschlag hilft niemandem: an einem Entwurf sieht die Person sofort, was du
falsch verstanden hast. Erfinde dabei nichts, was nicht gesagt wurde; lass eine
Funktion lieber knapp als ausgedacht."""
# Die Formatregel steht auch hier, nicht nur im System-Prompt. Bei langen Antworten
# vergaß das Modell sie sonst und schrieb reinen Fließtext — die ganze Wortmeldung
# war verloren.
NACHRICHT = """{verlauf}
Neue Nachricht der Person:
{text}
Antworte ausschließlich mit dem JSON-Objekt, ohne Text davor oder danach."""
NACHBESSERN = """Deine Antwort war kein JSON.
Schreibe sie noch einmal, ausschließlich als das JSON-Objekt aus der Vorgabe —
keine Einleitung, kein Nachsatz, keine Code-Zäune."""
def _pfad(projekt: str):
return projekte.wurzel(projekt) / DATEI
def verlauf(projekt: str) -> list:
try:
eintraege = json.loads(_pfad(projekt).read_text())
except (OSError, ValueError):
return []
return eintraege if isinstance(eintraege, list) else []
def _sichere(projekt: str, eintraege: list):
ziel = _pfad(projekt)
neben = ziel.with_suffix(".json.neu")
neben.write_text(json.dumps(eintraege[-TIEFE:], indent=1, ensure_ascii=False) + "\n")
neben.replace(ziel)
def leeren(projekt: str):
_pfad(projekt).unlink(missing_ok=True)
def _punkte(text: str) -> list:
return [z.strip("- ").strip() for z in (text or "").splitlines() if z.strip()]
def _als_spec(f: dict) -> dict:
"""Eine Karte in genau der Form, in der das Modell antworten soll."""
s = f["sektionen"]
return {"titel": s["titel"], "art": s["art"], "ziel": s["ziel"],
"regeln": _punkte(s["regeln"]),
"funktionen": [{"titel": x["text"], "ziel": x["ziel"],
"regeln": _punkte(x["regeln"]),
"testen": _punkte(x["testen"])}
for x in s["funktionen"]]}
def _kartenstand(projekt: str) -> str:
"""Der Stand als JSON — dieselbe Form wie die Antwort.
Vorher stand er als Fließtext da. Das Modell musste ihn beim Zurückschreiben
übersetzen und schob dabei Regeln zwischen Karten- und Funktionsebene hin und
her: aus „eine Funktion ergänzen" wurden vier Änderungen. Gleiche Form heißt,
dass Abschreiben nichts kostet.
"""
vision, karten = "", []
for f in features.liste(projekt):
if f["sektionen"]["art"] == parser.VISION:
vision = f["sektionen"]["ziel"]
else:
karten.append(_als_spec(f))
text = json.dumps(karten, indent=1, ensure_ascii=False) if karten \
else "Noch keine Karten."
if vision:
text = f"So ist das Projekt gedacht:\n\n{vision}\n\nDie Karten:\n\n{text}"
return text
def _zeilen(wert) -> str:
"""regeln und testen kommen als Liste oder als Block — hier wird eins daraus."""
if isinstance(wert, list):
eintraege = [str(z).strip("- ").strip() for z in wert]
else:
eintraege = [z.strip("- ").strip() for z in str(wert or "").splitlines()]
return "\n".join(f"- {z}" for z in eintraege if z)
TRENNER = "-" * 40
def roh(karte: dict) -> str:
"""Aus dem Sollzustand die Markdown-Datei — dieselbe Form wie im Frontend."""
art = str(karte.get("art") or "kern").strip().lower()
teile = [f'<!-- titel -->\n{str(karte.get("titel") or "").strip()}',
f'<!-- art -->\n{art if art in parser.ARTEN else "kern"}']
if str(karte.get("ziel") or "").strip():
teile.append(f'<!-- ziel -->\n{str(karte["ziel"]).strip()}')
if _zeilen(karte.get("regeln")):
teile.append(f'<!-- regeln -->\n{_zeilen(karte.get("regeln"))}')
for f in karte.get("funktionen") or []:
block = [f'<!-- funktion -->\n{str(f.get("titel") or "").strip()}']
if f.get("stand") == parser.OFFEN:
block.append(f'<!-- stand -->\n{parser.OFFEN}')
if str(f.get("ziel") or "").strip():
block.append(f'<!-- ziel -->\n{str(f["ziel"]).strip()}')
for feld in ("regeln", "testen"):
if _zeilen(f.get(feld)):
block.append(f'<!-- {feld} -->\n{_zeilen(f.get(feld))}')
teile.append("\n".join(block))
return f"\n{TRENNER}\n".join(teile) + "\n"
def _felder(sektionen: dict) -> dict:
return {"titel": sektionen["titel"], "badge": sektionen["art"],
"ziel": sektionen["ziel"], "regeln": sektionen["regeln"],
"funktionen": "\n".join(f["text"] for f in sektionen["funktionen"])}
def _vergleiche(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
"""Was sich an einer bestehenden Karte ändert, Feld für Feld.
Auf Funktionsebene wird nur die Liste der Titel verglichen. Wer den Inhalt einer
Funktion ändern will, bekommt dafür eigene Stellen — sonst wäre ein Vorschlag ein
Klotz, den man nur ganz oder gar nicht nehmen kann.
"""
raus = []
for feld in ("titel", "badge", "ziel", "regeln", "funktionen"):
for p in pruefung._stellen(feld, alt.get(feld, ""), neu.get(feld, ""),
grund, "Aus dem Gespräch"):
p["feature"] = feature_id
p["funktion"] = ""
# Was das Gespräch an Funktionen erfindet, ist bestellt, nicht gebaut.
# Dieselbe Stelle aus Prüfung 3 hätte belegten Code — dort wäre „offen" falsch.
if feld == "funktionen":
p["neu_offen"] = True
raus.append(p)
return raus
def _funktionsstellen(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
"""Änderungen innerhalb einer Funktion, je Feld eine eigene Stelle."""
vorher = {f["text"]: f for f in alt["funktionen"]}
raus = []
for f in neu["funktionen"]:
bestand = vorher.get(f["text"])
if not bestand:
continue # neue Funktion: steckt schon im Listenvergleich
for feld in ("ziel", "regeln", "testen"):
for p in pruefung._stellen(feld, bestand[feld], f[feld], grund,
"Aus dem Gespräch"):
p["feature"] = feature_id
p["funktion"] = f["text"]
raus.append(p)
return raus
def vorschlaege(projekt: str, karten: list) -> list:
"""Aus den Sollzuständen die einzelnen Stellen — bestehende Karten wie neue."""
nach_titel = {f["sektionen"]["titel"].lower(): f for f in features.liste(projekt)}
raus = []
for karte in karten:
if not isinstance(karte, dict) or not str(karte.get("titel") or "").strip():
continue
titel = str(karte["titel"]).strip()
# Den Stand entscheidet der Code, nicht das Modell: was schon gebaut war,
# bleibt gebaut; was das Gespräch neu erfindet, ist bestellt und noch offen.
# Das Modell sieht das Feld gar nicht — ein Feld weniger, das es vergessen kann.
bestand = nach_titel.get(titel.lower())
gebaut = {x["text"] for x in bestand["sektionen"]["funktionen"]
if x["stand"] == parser.GEBAUT} if bestand else set()
for f in karte.get("funktionen") or []:
f["stand"] = parser.GEBAUT if str(f.get("titel") or "").strip() in gebaut \
else parser.OFFEN
text = roh(karte)
neu = parser.zerlege(text)
if bestand is None:
# Neue Karte: es gibt keine Stelle, an der man ansetzen könnte —
# deshalb ein eigener Vorschlag, der die ganze Datei mitbringt.
raus.append({"feld": "neue_karte", "original": "", "vorschlag": titel,
"roh": text, "ganzes_feld": True, "feature": "",
"funktion": "", "regel": "Aus dem Gespräch",
"problem": "Neue Karte"})
continue
grund = f"Aus dem Gespräch: {titel}"
raus += _vergleiche(_felder(bestand["sektionen"]), _felder(neu),
bestand["id"], grund)
raus += _funktionsstellen(bestand["sektionen"], neu, bestand["id"], grund)
return raus
async def frage(projekt: str, text: str) -> dict:
"""Eine Runde: Frage rein, Antwort und Vorschläge raus."""
text = (text or "").strip()
if not text:
raise ValueError("Nachricht ist leer")
agents.pruefe_konfiguration()
bisher = verlauf(projekt)
gespraech = "\n\n".join(f'{"Person" if e["rolle"] == "person" else "Du"}: {e["text"]}'
for e in bisher) or "Noch nichts besprochen."
# Beide Kataloge: der Chat schreibt Karten UND Funktionen. Vorher kannte er nur
# die Kartenregeln und erfand Funktionstitel wie „Galerie im Modal" — ein
# Bereich statt einer Handlung, und die Prüfung merkte es erst hinterher.
system = SYSTEM.format(
stil="\n\n".join(pruefung._stil_lesen(b) for b in pruefung.BEREICHE),
karten=_kartenstand(projekt))
nachricht = NACHRICHT.format(verlauf=gespraech, text=text)
# Ein Ausrutscher beim JSON kostet sonst die ganze Wortmeldung. Zweiter Versuch
# mit etwas mehr Streuung; danach fliegt der Fehler nach oben und ist sichtbar.
daten = None
for versuch in range(3):
antwort = await agents.frage(system, nachricht, TEMPERATUR + 0.1 * versuch)
try:
daten = pruefung._json_lesen(antwort)
break
except ValueError:
if versuch == 2:
raise ValueError("Antwort war nicht lesbar — bitte noch einmal senden")
# Nachbessern statt blind wiederholen: das Modell sieht seinen eigenen
# Fehlversuch und weiß, was daran fehlte.
nachricht = (f"{nachricht}\n\nDein letzter Versuch war:\n"
f"{antwort[:2000]}\n\n{NACHBESSERN}")
satz = str(daten.get("antwort") or "").strip() or "Verstanden."
probleme = vorschlaege(projekt, daten.get("karten") or [])
bisher += [{"rolle": "person", "text": text}, {"rolle": "ki", "text": satz}]
_sichere(projekt, bisher)
return {"antwort": satz, "probleme": probleme, "verlauf": verlauf(projekt)}

View File

@@ -27,6 +27,13 @@ MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "")
MINIMAX_MODELL = os.environ.get("MINIMAX_MODELL", "")
MINIMAX_URL = os.environ.get("MINIMAX_URL", "")
# Das Gespräch darf woanders laufen als die Prüfungen. Es ist ein einzelner Aufruf
# und lebt von Sprachgefühl; die Prüfungen sind hundert Stimmen und leben von der
# Temperaturstreuung. Nicht gesetzt heißt: alles über MiniMax.
CHAT_API_KEY = os.environ.get("CHAT_API_KEY", "")
CHAT_MODELL = os.environ.get("CHAT_MODELL", "")
CHAT_URL = os.environ.get("CHAT_URL", "https://api.anthropic.com/v1/messages")
def _zahl(name: str, standard: int) -> int:
try:

View File

@@ -6,12 +6,15 @@ import parser
import projekte
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
# Dateisysteme lassen 255 Zeichen je Name zu. Der Titel darf länger sein — er steht
# in der Datei, nicht im Namen. Ohne Deckel warf ein langer Titel einen Serverfehler.
NAMENSLAENGE = 80
def _slug(titel: str) -> str:
s = titel.lower().translate(_UMLAUTE)
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return s or "feature"
return s[:NAMENSLAENGE].strip("-") or "feature"
def _eintrag(datei) -> dict:
@@ -26,8 +29,14 @@ def _datei(projekt: str, id: str):
return datei
def liste(projekt: str) -> list[dict]:
dateien = projekte.wurzel(projekt).glob("*.md")
def liste(projekt: str, nur_stand: bool = False) -> list[dict]:
"""Die Karten. Standardmäßig die, an denen gerade gearbeitet wird.
`nur_stand` liest stattdessen den Projektordner — die Übersicht soll sagen,
was das Projekt heute kann, nicht was jemand gerade plant.
"""
ordner = projekte.stand(projekt) if nur_stand else projekte.wurzel(projekt)
dateien = ordner.glob("*.md")
eintraege = [_eintrag(d) for d in sorted(dateien)]
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
@@ -38,17 +47,21 @@ def pruefbar(projekt: str) -> list[dict]:
return [f for f in liste(projekt) if f["sektionen"]["art"] != parser.VISION]
def anlegen(projekt: str, roh: str) -> dict:
ordner = projekte.wurzel(projekt)
titel = parser.zerlege(roh)["titel"]
if not titel:
raise ValueError("Titel fehlt")
slug = _slug(titel)
def _frei(ordner, slug: str):
"""Der erste freie Dateiname zum Slug; gleiche Namen bekommen eine Nummer."""
datei = ordner / f"{slug}.md"
n = 2
while datei.exists():
datei = ordner / f"{slug}-{n}.md"
n += 1
return datei
def anlegen(projekt: str, roh: str) -> dict:
titel = parser.zerlege(roh)["titel"]
if not titel:
raise ValueError("Titel fehlt")
datei = _frei(projekte.wurzel(projekt), _slug(titel))
datei.write_text(roh)
return _eintrag(datei)
@@ -58,11 +71,7 @@ def _folge_titel(datei, titel: str):
slug = _slug(titel)
if re.fullmatch(re.escape(slug) + r"(-\d+)?", datei.stem):
return datei # passt schon (inkl. Kollisions-Suffix)
ziel = datei.parent / f"{slug}.md"
n = 2
while ziel.exists():
ziel = datei.parent / f"{slug}-{n}.md"
n += 1
ziel = _frei(datei.parent, slug)
datei.rename(ziel)
return ziel
@@ -72,9 +81,35 @@ def speichern(projekt: str, id: str, roh: str) -> dict:
titel = parser.zerlege(roh)["titel"]
if not titel:
raise ValueError("Titel fehlt")
vorher = datei.read_text()
datei.write_text(roh)
return _eintrag(_folge_titel(datei, titel))
neu = _folge_titel(datei, titel)
# Ein neuer Titel benennt die Datei um. Der Rückschritt muss beides kennen:
# die neue Datei fällt weg, die alte kommt mit ihrem alten Inhalt zurück.
schritt = {datei.stem: vorher}
if neu.stem != datei.stem:
schritt[neu.stem] = None
return _eintrag(neu)
def loeschen(projekt: str, id: str):
_datei(projekt, id).unlink()
datei = _datei(projekt, id)
titel = parser.zerlege(datei.read_text())["titel"] or id
datei.unlink()
def verschieben(projekt: str, id: str, ziel: str) -> dict:
"""Ein Feature in ein anderes Projekt umhängen.
Kein Kopieren: die Datei wandert, damit es sie nur einmal gibt. Der Verlauf
hält beide Seiten fest — sonst ließe sich der Schritt nicht zurücknehmen.
"""
if ziel == projekt:
raise ValueError("Feature liegt schon in diesem Projekt")
datei = _datei(projekt, id)
roh = datei.read_text()
titel = parser.zerlege(roh)["titel"] or id
# Erst das Ziel auflösen: gibt es das Projekt nicht, ist noch nichts passiert
neu = _frei(projekte.wurzel(ziel), _slug(titel))
datei.rename(neu)
return _eintrag(neu)

View File

@@ -1,748 +0,0 @@
"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? Dazu:
beschreiben zwei Funktionen dieselbe Fähigkeit?
**Code im Feature** — hat jede Stelle im Code auch eine Funktion auf einer Karte?
Beide arbeiten mit derselben Beweislast, nur andersherum.
Ein Vorhandensein lässt sich zitieren, ein Nichtvorhandensein nicht. Darum fragt
„Feature im Code" sechsmal unabhängig nach dem Beleg und meldet erst, wenn keine
einzige Stimme eine Codezeile findet, die auch wirklich dasteht. Ein geprüfter Fund
genügt zum Freispruch — sechs Fehlversuche sind die Anklage. Ein früherer Versuch
ließ das Modell direkt urteilen und schlug dabei vor, gültige Inhalte zu löschen.
„Code im Feature" fragt umgekehrt je Stelle, ob eine bestehende Funktion sie
abdeckt, und meldet ab fünf von sechs Nein-Stimmen. Einstimmigkeit war zu scharf:
dieselbe Frage kippt auch bei Temperatur 0 gelegentlich.
Was der Code selbst weiß, entscheidet der Code: die Liste der Bedienstellen kommt
aus `quellcode.interaktionen()`, jeder Beleg wird gegen die Datei geschlagen.
Jedes Urteil wandert in `ledger`. Der nächste Lauf fragt nur noch, was er nicht
schon weiß — dadurch liefert derselbe Stand dasselbe Ergebnis. Auch das unentschiedene
Urteil wird gemerkt: es galt als abgedeckt, und ein Münzwurf, der jede Runde neu
fällt, ist schlimmer als einer, der einmal fällt. Ändert sich die Stelle im Code,
wird sie ohnehin neu beurteilt.
"""
import asyncio
import difflib
import re
from collections import Counter
import agents
import config
import ledger
import quellcode
# Ohne Drossel liefen alle Anfragen gleichzeitig los und der Provider antwortete
# mit 429. Die ausgefallenen Stimmen sahen dann aus wie „nichts gefunden".
_schranke = None
def _drossel():
global _schranke
if _schranke is None:
_schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
return _schranke
async def _frage(system: str, nachricht: str, temperatur: float) -> str:
async with _drossel():
return await agents.frage(system, nachricht, temperatur)
async def _urteil(system: str, nachricht: str, temperatur: float):
"""Eine Stimme als JSON. Unlesbares wird einmal neu gefragt, dann aufgegeben —
bei hunderten Anfragen je Lauf ist ein Ausrutscher normal, ein Abbruch nicht."""
for versuch in range(3):
try:
return _json(await _frage(system, nachricht, temperatur + 0.1 * versuch))
except ValueError:
continue
return None
# Achtung: diese Texte gehen unformatiert als System-Prompt raus. Doppelte Klammern
# wären hier keine Escapes, sondern landeten wörtlich in der Antwort.
SUCHE_SYSTEM = """Du suchst im Quelltext den Code zu einer beschriebenen Funktion.
Antworte ausschließlich mit JSON:
{"gefunden": true, "datei": "...", "beleg": "..."}
oder
{"gefunden": false, "grund": "..."}
- datei: der Pfad genau so, wie er über der Datei steht
- beleg: eine Zeile aus dieser Datei, Zeichen für Zeichen abgeschrieben
- grund: höchstens 8 Wörter, wenn es den Code nicht gibt
Suche gründlich und im ganzen Quelltext. Es zählt nicht nur, was einen Knopf hat:
Hintergrundlogik, Parser, Dateiverwaltung, Prüfregeln und Anzeigeverhalten sind
genauso Code. Ein erfundener Beleg ist wertlos — er wird nachgeschlagen."""
SUCHE_NACHRICHT = """{quelle}
Ende des Quelltextes.
Eine Karte beschreibt diese Funktion:
{funktion}
Finde die Stelle im Quelltext, die das umsetzt. Antworte ausschließlich mit dem
JSON-Objekt."""
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
ABDECKUNG = """Diese Stelle im Programm lässt sich auslösen:
{art} in {datei}, Zeile {zeile}
```
{umfeld}
```
{rumpf}
Die Karten des Projekts beschreiben bisher das hier:
{funktionen}
Beschreibt eine dieser Funktionen, was an der Stelle oben passiert?
Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte
einer Funktion gehören mit zu dem, was sie abdeckt, und die Regeln der Karte
ebenso: eine Zusicherung wie „alphabetisch sortiert" ist beschrieben, auch wenn
kein Knopf dazu gehört.
Antworte ausschließlich mit JSON:
{{"abgedeckt": true, "funktion": "..."}}
oder
{{"abgedeckt": false, "karte": "...", "titel": "..."}}
- funktion: der bestehende Funktionstitel, wörtlich, wenn abgedeckt
- karte: der Titel der Karte, in die die Fähigkeit thematisch gehört
- titel: wie die neue Funktion heißen soll — Objekt und Verb im Infinitiv,
höchstens 5 Wörter
Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines
Feldes, Abbrechen mit Escape, Gesundheitsprüfungen, interne Weiterleitung.
Fehlerbehandlung ebenso — eine Fehlermeldung ist kein eigener Auftrag, sondern
gehört zu der Fähigkeit, bei der etwas schiefging.
Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen."""
DUBLETTE_SYSTEM = """Du entscheidest, ob zwei Funktionsbeschreibungen dieselbe
Fähigkeit meinen. Antworte ausschließlich mit JSON."""
DUBLETTE = """Zwei Funktionen aus den Karten eines Projekts:
A
{text_a}
B
{text_b}
Aufgefallen sind sie so: {anlass}
Beantworte der Reihe nach, allein aus Titel und Testschritten:
1. Woran arbeitet A? Der Gegenstand, an dem sich etwas ändert.
2. Woran arbeitet B?
3. Meinen 1 und 2 denselben Gegenstand?
4. Was ist anders, nachdem A ausgeführt wurde?
5. Was ist anders, nachdem B ausgeführt wurde?
6. Beschreiben 4 und 5 denselben Zustand? Es zählt das Ergebnis, nicht das Wort:
verschiedene Wörter für dasselbe Ergebnis sind gleich, ein Gegenstück nicht.
Antworte ausschließlich mit JSON:
{{"gegenstand_a": "...", "gegenstand_b": "...", "gegenstand_gleich": true,
"ergebnis_a": "...", "ergebnis_b": "...", "ergebnis_gleich": true}}
Urteile nicht selbst über die Dublette. Ob es eine ist, wird aus deinen Antworten
auf 3 und 6 errechnet."""
# Sechs unabhängige Versuche, den Beleg zu finden. Eine einzige Stimme, die etwas
# findet, genügt zum Freispruch — die Anklage lautet auf Löschen, da wiegt ein
# Fehlurteil schwerer als ein übersehener Rest.
SUCHE_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
# So viele Stimmen müssen überhaupt geantwortet haben, bevor angeklagt wird
ANKLAGE_AB = 4
# Sechs Stimmen, fünf müssen „fehlt" sagen. Eindeutige Stellen antworten 6:0 oder
# 0:6, Grenzfälle etwa 3:3 — die Schwelle bei fünf trennt beides.
URTEIL_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
FEHLT_AB = 5
# So viele Stimmen müssen geantwortet haben, bevor überhaupt geurteilt wird. Das
# Urteil wird gemerkt und gilt dann dauerhaft — dafür reichen zwei Überlebende nicht.
URTEIL_AB = 4
# Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
GLEICH = 0.75
# Drei Stimmen über ein Paar, einstimmig für die Dublette. Ein Löschvorschlag darf
# nicht an einer Einzelmeinung hängen.
DUBLETTE_TEMPERATUREN = (0.0, 0.3, 0.6)
# Kandidatenkanal „ähnlicher Titel". Höher als GLEICH: hier zahlt jedes Paar drei
# Anfragen, und „Feature anlegen"/„Feature entfernen" liegen schon bei 0,75.
TITEL_KANAL = 0.85
# Kandidatenkanal „ähnlicher Inhalt". Anteil der Wörter, die die kürzere der beiden
# Beschreibungen mit der längeren teilt. Überlappung statt Jaccard, weil eine knappe
# Dublette sonst an der Länge der ausführlicheren scheitert.
INHALT_KANAL = 0.7
# Wörter, die in so vielen Funktionen vorkommen, tragen nichts zur Unterscheidung
# bei. „Vollmodus öffnen", „Text ändern" stehen fast überall — ohne diesen Filter
# ähnelt jede Bearbeiten-Funktion jeder anderen zu 90 %.
FLOSKEL_ANTEIL = 0.2
# Deckel gegen Explosion bei vielen Funktionen; was darüber liegt, meldet der Lauf
DUBLETTEN_CAP = 40
# Ein gemerktes Urteil gehört zu der Frage, auf die es geantwortet hat. Wird die
# Frage umgeschrieben, ist das Urteil von gestern — deshalb hängt der Fingerabdruck
# am Wortlaut des Prompts. Sonst müsste man daran denken, eine Zahl hochzuzählen.
_SUCHE_STAND = ledger.abdruck(SUCHE_SYSTEM, SUCHE_NACHRICHT)
_ABDECKUNG_STAND = ledger.abdruck(ABDECKUNG_SYSTEM, ABDECKUNG)
_DUBLETTE_STAND = ledger.abdruck(DUBLETTE_SYSTEM, DUBLETTE)
def _normal(text: str) -> str:
return re.sub(r"[^a-zä-ü0-9 ]", "", (text or "").lower()).strip()
def _aehnlich(a: str, b: str) -> float:
return difflib.SequenceMatcher(None, _normal(a), _normal(b)).ratio()
def _json(antwort: str) -> dict:
import pruefung # zur Laufzeit, sonst Ringschluss
return pruefung._json_lesen(antwort)
async def _sammle(aufgaben: list, was: str, melde=None) -> list:
"""Alle Stellen beurteilen, auch wenn einzelne platzen.
Vorher riss die erste kaputte Stelle den ganzen Lauf ab. Zwei Folgen, beide
schlecht: die schon bezahlten Urteile der anderen waren weg, und das Gedächtnis
wurde gespeichert, während die übrigen Anfragen noch liefen — es blieb leer.
Jetzt wird gewartet, bis alle durch sind. Was ging, zählt; was nicht ging, steht
in der Meldung. Der nächste Lauf holt nur den Rest nach.
"""
ergebnisse = await asyncio.gather(*aufgaben, return_exceptions=True)
kaputt = [e for e in ergebnisse if isinstance(e, BaseException)]
if kaputt and melde:
melde(f"{len(kaputt)} von {len(ergebnisse)} {was} nicht beurteilt: "
f"{kaputt[0]}", len(kaputt))
return [x for x in ergebnisse if x and not isinstance(x, BaseException)]
# ---------------------------------------------------------------- Feature im Code
def _funktionstext(karte: str, f: dict) -> str:
"""Alles, was die Funktion behauptet — danach wird im Code gesucht.
Ohne Karte, wenn die anderswo schon dasteht: doppelt genannt zog sie beim
Dublettenurteil mehr Gewicht auf sich als Titel und Testschritte zusammen.
"""
zeilen = [f"Karte: {karte}"] if karte else []
zeilen.append(f'Funktion: {f["text"]}')
if f["ziel"]:
zeilen.append(f'Ziel: {f["ziel"]}')
for feld in ("regeln", "testen"):
if f[feld]:
eintraege = [z.strip("- ").strip() for z in f[feld].splitlines() if z.strip()]
zeilen.append(f'{feld.capitalize()}: {"; ".join(eintraege)}')
return "\n".join(zeilen)
def _quellabdruck(quelle: dict) -> str:
return ledger.abdruck(_SUCHE_STAND,
*(f"{p}\n{t}" for p, t in sorted(quelle.items())))
async def feature_im_code(features: list, quelle: dict, quelltext: str, stand: dict,
zaehl=None, melde=None) -> list:
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet.
Ein gemerkter Beleg zählt weiter, solange die zitierte Zeile noch dasteht —
dafür braucht es kein Modell, das prüft `quellcode.belegt()` nach. Eine gemerkte
Fehlanzeige gilt nur, solange sich am Quelltext nichts geändert hat: sie fordert
zum Löschen auf, und dafür muss der Befund frisch sein.
"""
quellstand = _quellabdruck(quelle)
auftraege = [(f, x) for f in features for x in f["sektionen"]["funktionen"]]
bekannt = {ledger.schluessel(f["id"], x["text"]) for f, x in auftraege}
stand["belege"] = ledger.behalte(stand["belege"], bekannt)
stand["ohne_code"] = ledger.behalte(stand["ohne_code"], bekannt)
async def eine(paar):
feature, x = paar
schluessel = ledger.schluessel(feature["id"], x["text"])
text = _funktionstext(feature["sektionen"]["titel"], x)
wie_gehabt = ledger.abdruck(text)
gemerkt = stand["belege"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt and quellcode.belegt(
quelle, gemerkt["datei"], gemerkt["beleg"]):
if zaehl:
zaehl()
return None
gemerkt = stand["ohne_code"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
and gemerkt["quelle"] == quellstand:
if zaehl:
zaehl()
return {"feature": feature["id"], "titel": x["text"],
"art": "ohne_code", "grund": gemerkt["grund"]}
nachricht = SUCHE_NACHRICHT.format(quelle=quelltext, funktion=text)
proben = await asyncio.gather(*(
_urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN
), return_exceptions=True)
if zaehl:
zaehl()
gruende = []
brauchbar = behauptet = 0
beleg = None
for u in proben:
if not isinstance(u, dict):
continue
brauchbar += 1
if u.get("gefunden"):
# Auch ein misslungenes Zitat ist ein Widerspruch. Ein Modell
# verschreibt sich beim Abschreiben von Backticks — daraus eine
# Löschempfehlung zu machen, wäre grob falsch.
behauptet += 1
datei = str(u.get("datei") or "").strip()
zitat = str(u.get("beleg") or "").strip()
if beleg is None and quellcode.belegt(quelle, datei, zitat):
beleg = {"abdruck": wie_gehabt, "datei": datei, "beleg": zitat}
elif u.get("grund"):
gruende.append(str(u["grund"]).strip())
stand["belege"].pop(schluessel, None)
stand["ohne_code"].pop(schluessel, None)
if beleg:
stand["belege"][schluessel] = beleg
return None # geprüfter Beleg: die Funktion steht im Code
if behauptet:
return None # widersprüchliche Stimmen: nichts behaupten und nichts merken
if brauchbar < ANKLAGE_AB:
# Zu wenige Stimmen für eine Löschempfehlung. Lieber laut scheitern
# als eine gültige Funktion wegen eines Ausfalls anzuklagen.
raise RuntimeError(
f'{x["text"]}: nur {brauchbar} von '
f"{len(SUCHE_TEMPERATUREN)} Stimmen brauchbar")
grund = gruende[0] if gruende else "Kein Beleg im Code gefunden"
stand["ohne_code"][schluessel] = {"abdruck": wie_gehabt, "quelle": quellstand,
"grund": grund}
return {"feature": feature["id"], "titel": x["text"],
"art": "ohne_code", "grund": grund}
return await _sammle([eine(a) for a in auftraege], "Funktionen", melde)
# ---------------------------------------------------------------- Dubletten
def _funktionen(features: list) -> dict:
"""schluessel -> alles, was zur Funktion gehört."""
raus = {}
for f in features:
for x in f["sektionen"]["funktionen"]:
raus[ledger.schluessel(f["id"], x["text"])] = {
"feature": f["id"], "karte": f["sektionen"]["titel"],
"titel": x["text"], "eintrag": x,
"text": _funktionstext("", x),
# Ohne die Feldnamen: „Regeln:" steht über jedem Block, und eine
# Funktion namens „Regeln anpassen" verlöre sonst ihr eigenes Wort
"worte": " ".join((f["sektionen"]["titel"], x["text"], x["ziel"],
x["regeln"], x["testen"]))}
return raus
def _gewicht(f: dict) -> tuple:
"""Wer mehr sagt, bleibt stehen. Bei Gleichstand entscheidet der Name — nicht
schön, aber vorhersagbar; ein Zufallssieger machte den Vorschlag unstet."""
x = f["eintrag"]
zeilen = len([z for z in (x["regeln"] + "\n" + x["testen"]).splitlines() if z.strip()])
return (zeilen, len(x["ziel"]), f["titel"])
_WORT = re.compile(r"[a-zä-üß0-9]{4,}")
# Beugung abschneiden statt richtig stemmen: „entfernen" und „entfernt" sind
# dasselbe Wort, und ein Stemmer legte die Sprache im Code fest.
STAMM = 5
def _kerne(alle: dict) -> dict:
"""Je Funktion die Wörter, die sie von den anderen unterscheiden.
Was in jeder fünften Funktion steht, fliegt raus. Sonst misst der Vergleich nur
noch die gemeinsame Schrittfolge, und „Titel anpassen" und „Ziel anpassen"
stimmen zu hundert Prozent überein.
"""
mengen = {k: set(_WORT.findall(v["worte"].lower())) for k, v in alle.items()}
haeufig = Counter(wort for m in mengen.values() for wort in m)
grenze = max(3, int(len(mengen) * FLOSKEL_ANTEIL))
return {k: {w[:STAMM] for w in m if haeufig[w] < grenze} for k, m in mengen.items()}
def _ueberlappung(a: set, b: set) -> float:
return len(a & b) / min(len(a), len(b)) if a and b else 0.0
def _kandidaten(alle: dict, belege: dict) -> list[tuple]:
"""Paare, die überhaupt eine Dublette sein könnten, mit dem Anlass.
Drei Kanäle, weil eine Dublette auf drei Arten auffällt: dieselbe Codezeile als
Beleg, fast derselbe Titel, fast dieselben Wörter. Der letzte fängt, was die
ersten beiden durchlassen — „Funktion löschen" und „Funktion entfernen" haben
weder denselben Titel noch zwingend denselben Beleg.
Jedes Paar zu fragen wäre quadratisch und träfe fast nie. Erst billig
eingrenzen, dann einmal teuer urteilen.
"""
paare = {}
def merke(a, b, anlass):
if a != b:
paare.setdefault(ledger.paarschluessel(a, b), (a, b, anlass))
nach_stelle = {}
for name, eintrag in belege.items():
if name in alle:
stelle = (eintrag["datei"], re.sub(r"\s+", " ", eintrag["beleg"]).strip())
nach_stelle.setdefault(stelle, []).append(name)
for (datei, zeile), gruppe in nach_stelle.items():
gruppe = sorted(gruppe)
for i, a in enumerate(gruppe):
for b in gruppe[i + 1:]:
merke(a, b, f"Beide belegen dieselbe Zeile in {datei}: {zeile}")
kern = _kerne(alle)
namen = sorted(alle)
for i, a in enumerate(namen):
for b in namen[i + 1:]:
if _aehnlich(alle[a]["titel"], alle[b]["titel"]) >= TITEL_KANAL:
merke(a, b, "Die Titel sind fast gleich.")
elif _ueberlappung(kern[a], kern[b]) >= INHALT_KANAL:
merke(a, b, "Beide Beschreibungen bestehen fast aus denselben Wörtern.")
return [(k, *paare[k]) for k in sorted(paare)]
async def _ist_dublette(a: dict, b: dict, anlass: str) -> bool:
"""Einstimmig gleicher Gegenstand und gleiches Ergebnis — sonst keine Dublette.
Das Gesamturteil rechnet der Code aus den Teilantworten. Gefragt nach der
Dublette an sich, widersprach eine Stimme ihrer eigenen Analyse: derselbe
Gegenstand, dasselbe Tun, und trotzdem „nein". Zwei kleine Fragen beantwortet
sie verlässlich, die große nicht.
Gefragt wird nach dem Ergebnis, nicht nach dem Verb. „Löschen" und „entfernen"
hielten zwei von drei Stimmen für verschiedenes Tun — was danach anders ist,
beschrieben sie gleich.
"""
nachricht = DUBLETTE.format(text_a=a["text"], text_b=b["text"], anlass=anlass)
proben = await asyncio.gather(*(
_urteil(DUBLETTE_SYSTEM, nachricht, t) for t in DUBLETTE_TEMPERATUREN
), return_exceptions=True)
stimmen = [p for p in proben if isinstance(p, dict)]
if len(stimmen) < len(DUBLETTE_TEMPERATUREN):
raise RuntimeError(f'Dublette {a["titel"]}/{b["titel"]}: '
f"nur {len(stimmen)} von "
f"{len(DUBLETTE_TEMPERATUREN)} Stimmen brauchbar")
return all(u.get("gegenstand_gleich") is True and u.get("ergebnis_gleich") is True
for u in stimmen)
async def dubletten(features: list, stand: dict, melde=None) -> list:
"""Funktionen, die eine andere Funktion doppelt beschreiben.
Gemeldet wird immer die schwächere von beiden — die stärkere bleibt stehen.
Ein einmal gefälltes Urteil bleibt gemerkt, auch das ablehnende: sonst zahlt
jeder Lauf erneut für dieselbe Frage und antwortet womöglich anders.
"""
alle = _funktionen(features)
paare = _kandidaten(alle, stand["belege"])
stand["paare"] = ledger.behalte(stand["paare"], {p[0] for p in paare})
if len(paare) > DUBLETTEN_CAP:
if melde:
melde(f"{len(paare)} Kandidatenpaare, geprüft wurden {DUBLETTEN_CAP}")
paare = paare[:DUBLETTEN_CAP]
async def urteile(paar):
schluessel, a, b, anlass = paar
links, rechts = alle[a], alle[b]
wie_gehabt = ledger.abdruck(_DUBLETTE_STAND, links["text"], rechts["text"])
gemerkt = stand["paare"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt:
gleich = gemerkt["gleich"]
else:
gleich = await _ist_dublette(links, rechts, anlass)
stand["paare"][schluessel] = {"abdruck": wie_gehabt, "gleich": gleich}
if not gleich:
return None
schwach, stark = sorted((links, rechts), key=_gewicht)
return {"feature": schwach["feature"], "titel": schwach["titel"],
"art": "dublette",
"grund": f'Beschreibt „{stark["titel"]}“ '
f'(Karte {stark["karte"]}) ein zweites Mal'}
return await _sammle([urteile(p) for p in paare], "Funktionspaare", melde)
def _wegfall_grund(betroffen: list) -> str:
"""Was die Karte verliert, in einem Halbsatz. Ohne Trennung nach Anlass stand
über einer Dublette „ohne eigenen Code" — beides heißt streichen, aber aus
verschiedenen Gründen, und der Grund entscheidet, ob man zustimmt."""
teile = []
for art, eins, viele in (("dublette", "Funktion doppelt", "Funktionen doppelt"),
("ohne_code", "Funktion ohne eigenen Code",
"Funktionen ohne eigenen Code")):
n = sum(x["art"] == art for x in betroffen)
if n:
teile.append(f"{n} {eins if n == 1 else viele}")
return ", ".join(teile)
def probleme_feature(features: list, ohne_code: list) -> list:
"""Ein Vorschlag je Karte: die betroffenen Funktionen aus der Liste nehmen."""
import pruefung
raus = []
for f in features:
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
betroffen, gesehen = [], set()
for x in ohne_code:
if x["feature"] == f["id"] and x["titel"] not in gesehen:
gesehen.add(x["titel"])
betroffen.append(x)
if not betroffen:
continue
weg = [x["titel"] for x in betroffen]
neu = [t for t in ist if t not in weg]
grund = _wegfall_grund(betroffen)
beleg = " · ".join(f'{x["titel"]}: {x["grund"]}' for x in betroffen)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
grund, beleg):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
# ---------------------------------------------------------------- Code im Feature
def _funktionsliste(features: list) -> str:
"""Die Karten mit allem, was sie sagen.
Nur die Titel reichten nicht: der Richter musste raten, ob eine Funktion eine
Stelle abdeckt, und urteilte von Runde zu Runde anders.
Die Regeln der Karte gehören dazu. Vieles, was ein Kunde bestellt, ist keine
Bedienung, sondern eine Zusicherung — „eine Datei je Feature", „alphabetisch
sortiert". Ohne sie sah der Richter den Code dahinter als unbeschrieben an.
"""
teile = []
for f in features:
s = f["sektionen"]
block = [f'## Karte: {s["titel"]}']
if s["ziel"]:
block.append(f'Ziel: {s["ziel"]}')
if s["regeln"]:
zeilen = ", ".join(z.strip("- ").strip()
for z in s["regeln"].splitlines() if z.strip())
block.append(f"Regeln der Karte: {zeilen}")
for x in s["funktionen"]:
block.append(f'\n### {x["text"]} (Karte: {s["titel"]})')
if x["ziel"]:
block.append(f'Ziel: {x["ziel"]}')
for feld in ("regeln", "testen"):
if x[feld]:
zeilen = ", ".join(z.strip("- ").strip()
for z in x[feld].splitlines() if z.strip())
block.append(f'{feld.capitalize()}: {zeilen}')
teile.append("\n".join(block))
return "\n\n".join(teile) or "—"
async def _abdeckung(punkt: dict, liste: str) -> list:
rumpf = f'\nDahinter steht:\n\n```\n{punkt["rumpf"]}\n```\n' if punkt["rumpf"] else ""
nachricht = ABDECKUNG.format(funktionen=liste, art=punkt["art"],
datei=punkt["datei"], zeile=punkt["zeile"],
umfeld=punkt["umfeld"], rumpf=rumpf)
proben = await asyncio.gather(*(
_urteil(ABDECKUNG_SYSTEM, nachricht, t) for t in URTEIL_TEMPERATUREN
), return_exceptions=True)
return [p for p in proben if isinstance(p, dict)]
def _naechste_karte(name: str, karten: dict, schwelle: float = 0.6):
if not name or not karten:
return None
treffer = max(karten, key=lambda k: _aehnlich(name, k))
return karten[treffer] if _aehnlich(name, treffer) >= schwelle else None
def _zielkarte(urteile: list, karten: dict):
"""Auf welche Karte gehört die fehlende Funktion?
Die Stimmen schlagen oft Karten vor, die es nicht gibt („Export",
„Datenverwaltung"). Vorher fiel der Befund dann weg — eine Stelle, die
einstimmig fehlte, wurde verschwiegen, weil man sich über die Ablage nicht
einig war. Jetzt gewinnt der erste Vorschlag, der sich auflösen lässt, und
zur Not der ähnlichste. Falsch abgelegt kann man verschieben; verschwiegen nicht.
"""
namen = Counter(str(u.get("karte") or "").strip() for u in urteile
if u.get("abgedeckt") is False and u.get("karte"))
for name, _ in namen.most_common():
if treffer := karten.get(name) or _naechste_karte(name, karten):
return treffer
haeufigste = namen.most_common(1)
return _naechste_karte(haeufigste[0][0], karten, schwelle=0.0) if haeufigste else None
def _stellenabdruck(punkt: dict) -> str:
return ledger.abdruck(_ABDECKUNG_STAND, punkt["beleg"], punkt["umfeld"],
punkt["rumpf"])
async def code_im_feature(features: list, punkte: list, stand: dict,
zaehl=None, melde=None) -> list:
"""Bedienstellen, die von keiner Funktion abgedeckt sind.
Ein gemerktes Urteil gilt, solange es tragen kann: „abgedeckt durch X", solange
X noch auf einer Karte steht; „fehlt als Y", solange es kein Y gibt. Beides
fällt weg, sobald sich die Codestelle selbst ändert.
"""
liste = _funktionsliste(features)
karten = {f["sektionen"]["titel"]: f for f in features}
kennungen = {f["id"] for f in features}
titel_da = [x["text"] for f in features for x in f["sektionen"]["funktionen"]]
normal_da = {_normal(t) for t in titel_da}
stand["abdeckung"] = ledger.behalte(stand["abdeckung"],
{p["schluessel"] for p in punkte})
stand["ohne_karte"] = ledger.behalte(stand["ohne_karte"],
{p["schluessel"] for p in punkte})
async def eine(punkt):
schluessel = punkt["schluessel"]
wie_gehabt = _stellenabdruck(punkt)
gemerkt = stand["abdeckung"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
and (not gemerkt["funktion"] or _normal(gemerkt["funktion"]) in normal_da):
if zaehl:
zaehl()
return None
gemerkt = stand["ohne_karte"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
and gemerkt["feature"] in kennungen \
and not any(_aehnlich(gemerkt["titel"], t) > GLEICH for t in titel_da):
if zaehl:
zaehl()
return {"feature": gemerkt["feature"], "titel": gemerkt["titel"],
"beleg": gemerkt["beleg"]}
urteile = await _abdeckung(punkt, liste)
if zaehl:
zaehl()
nein = sum(u.get("abgedeckt") is False for u in urteile)
ausgefallen = len(URTEIL_TEMPERATUREN) - len(urteile)
if len(urteile) < URTEIL_AB or nein < FEHLT_AB <= nein + ausgefallen:
# Zu dünn oder offen. Dünn: zwei überlebende Stimmen genügen rechnerisch
# für „abgedeckt", weil die Schwelle unerreichbar wird — als Dauerurteil
# wäre das zu wenig. Offen: die ausgefallenen Stimmen hätten es kippen
# können. Beides nicht verschweigen; sonst sähe der Ausfall aus wie „ist
# abgedeckt" und der Lauf meldete stillschweigend zu wenig.
raise RuntimeError(
f'{punkt["schluessel"]}: nur {len(urteile)} von '
f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar")
stand["abdeckung"].pop(schluessel, None)
stand["ohne_karte"].pop(schluessel, None)
# Steht das Ergebnis schon fest, zählen die fehlenden Stimmen nicht mehr.
# Vier Stimmen „abgedeckt" bei sechs möglichen können die Schwelle nicht
# mehr reißen — daraus einen Fehlschlag zu machen, wäre Verschwendung.
if nein < FEHLT_AB:
# Abgedeckt — oder unentschieden, was dasselbe Ergebnis hat. Gemerkt
# wird beides; der Name nur, wenn die Stimmen sich auf einen einigen.
namen = Counter(str(u.get("funktion") or "").strip()
for u in urteile if u.get("abgedeckt"))
name, treffer = namen.most_common(1)[0] if namen else ("", 0)
# Nur ein Name, den es wirklich gibt, taugt als Verfallsdatum. Deckt
# eine Regel die Stelle ab, nennt die Stimme deren Wortlaut — der
# stünde auf ewig ungültig in der Datei und würde jeden Lauf neu gefragt.
gilt = treffer >= len(urteile) / 2 and _normal(name) in normal_da
stand["abdeckung"][schluessel] = {"abdruck": wie_gehabt,
"funktion": name if gilt else ""}
return None
treffer = _zielkarte(urteile, karten)
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
if not treffer or not titel:
return None
gefunden = {"feature": treffer["id"],
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
stand["ohne_karte"][schluessel] = {"abdruck": wie_gehabt, **gefunden}
return gefunden
gefunden = await _sammle([eine(p) for p in punkte], "Codestellen", melde)
# Zwei Stellen können auf dieselbe fehlende Funktion zeigen
raus = []
for x in gefunden:
gleich = next((y for y in raus if y["feature"] == x["feature"]
and _aehnlich(y["titel"], x["titel"]) > GLEICH), None)
if gleich:
gleich["beleg"] += f', {x["beleg"]}'
else:
raus.append(dict(x))
return raus
def probleme_code(features: list, fehlend: list) -> list:
"""Ein Vorschlag je Karte: die fehlenden Funktionen anhängen."""
import pruefung
raus = []
for f in features:
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
dazu = [x for x in fehlend if x["feature"] == f["id"]]
if not dazu:
continue
# Bestehende Reihenfolge behalten, Neues hinten anhängen — sortieren würde
# aus einer Ergänzung einen Umbau machen. Angezeigt wird ohnehin alphabetisch.
neu = list(dict.fromkeys(ist + [x["titel"] for x in dazu]))
grund = (f"{len(dazu)} Stelle ohne Funktion" if len(dazu) == 1
else f"{len(dazu)} Stellen ohne Funktion")
beleg = " · ".join(x["beleg"] for x in dazu)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
grund, beleg):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
# ---------------------------------------------------------------- Einstiege
async def pruefe_feature_im_code(projekt: str, features: list, quelle: dict,
quelltext: str, zaehl=None, melde=None) -> list:
stand = ledger.lade(projekt)
try:
ohne = await feature_im_code(features, quelle, quelltext, stand, zaehl, melde)
doppelt = await dubletten(features, stand, melde)
finally:
# Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt
ledger.sichere(projekt, stand)
return probleme_feature(features, ohne + doppelt)
async def pruefe_code_im_feature(projekt: str, features: list, quelle: dict,
zaehl=None, melde=None) -> list:
punkte = quellcode.interaktionen(quelle)
if not punkte:
raise RuntimeError("Keine Bedienstellen im Quelltext gefunden")
stand = ledger.lade(projekt)
try:
fehlend = await code_im_feature(features, punkte, stand, zaehl, melde)
finally:
ledger.sichere(projekt, stand)
return probleme_code(features, fehlend)

BIN
backend/k1-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
backend/k1-1280.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
backend/k1-1600.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
backend/k1-390.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
backend/k1-640.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
backend/k1-820.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
backend/komfort.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -9,8 +9,7 @@ nur noch, was er nicht schon weiß. Ein Eintrag gilt weiter, solange seine
Voraussetzung nachweisbar dasteht:
- ein Beleg, solange die zitierte Zeile noch in der Datei steht
- eine Abdeckung, solange die nennende Funktion noch auf einer Karte steht
- eine Fehlmeldung, solange keine passende Funktion dazugekommen ist
- eine Fehlanzeige, solange sich am Quelltext nichts geändert hat
Fällt die Voraussetzung weg, fällt der Eintrag weg und wird neu gefragt. Damit
bleibt gleicher Stand gleiches Ergebnis, und geänderter Stand wird wirklich neu
@@ -30,11 +29,10 @@ DATEI = ".pruefung.json"
# Ein Fach je Frage. Getrennt, weil sie unterschiedlich ungültig werden.
FAECHER = (
"stil", # Prüfstelle -> ihr Stilurteil (Prüfung 1)
"belege", # Funktion -> Codestelle, die sie umsetzt (Prüfung 2)
"ohne_code", # Funktion -> kein Code gefunden (Prüfung 2)
"paare", # Funktionspaar -> Dublette oder verschieden (Prüfung 2)
"abdeckung", # Codestelle -> Funktion, die sie beschreibt (Prüfung 3)
"ohne_karte", # Codestelle -> keine Funktion beschreibt sie (Prüfung 3)
)

BIN
backend/offen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -19,6 +19,14 @@ TRENNER = re.compile(r"^-{3,}\s*$")
VISION = "vision"
ARTEN = ("kern", "optional", VISION)
# Eine Funktion ist entweder geliefert oder noch offen. „offen" heißt: die Karte
# beschreibt sie schon, den Code gibt es noch nicht — bestellt, nicht gebaut.
# Ohne diese Unterscheidung meldet die Vorwärtsprüfung jede geplante Funktion als
# Fehler und will sie streichen.
OFFEN = "offen"
GEBAUT = "gebaut"
STAENDE = (GEBAUT, OFFEN)
def _art(bloecke: list) -> str:
wert = bloecke[0].strip().lower() if bloecke else ""
@@ -61,7 +69,11 @@ def zerlege(roh: str) -> dict:
# vor der ersten Funktion: Karten-Ziel, danach: Ziel der Funktion
(funktionen[-1]["ziel"] if funktionen else ziel).append(text)
elif schluessel == "funktion":
funktionen.append({"text": text, "ziel": [], "regeln": [], "testen": []})
funktionen.append({"text": text, "ziel": [], "regeln": [], "testen": [],
"stand": GEBAUT})
elif schluessel == "stand" and funktionen:
wert = text.strip().lower()
funktionen[-1]["stand"] = wert if wert in STAENDE else GEBAUT
elif schluessel in ("regel", "regeln"):
# vor der ersten Funktion: Karten-Regeln, danach: Regeln der Funktion
(funktionen[-1]["regeln"] if funktionen else regeln).append(text)

163
backend/planung.py Normal file
View File

@@ -0,0 +1,163 @@
"""Eine Planung: der Kartensatz, wie er nach der nächsten Umsetzung aussehen soll.
Die Übersicht beantwortet „was kann mein Projekt". Eine geplante Funktion gehört
dort nicht hin — sie wäre gelogen. Deshalb liegt die Planung in einer eigenen
Kopie und der Projektordner bleibt unberührt, bis sie umgesetzt wird.
Kopiert wird der ganze Ordner, nicht nur die Karten. Gespräch und
Urteilsgedächtnis gehören zur Planung: was während des Planens besprochen und
beurteilt wurde, gilt für den geplanten Stand, nicht für den heutigen.
Solange eine Planung läuft, arbeitet `projekte.wurzel()` in der Kopie. Keine
Schreibfunktion muss davon wissen.
Es gibt höchstens eine Planung je Projekt. Sie endet auf zwei Arten: umgesetzt —
dann ersetzt die Kopie den Projektstand — oder abgebrochen, dann verschwindet sie
spurlos.
"""
import shutil
import parser
import projekte
# Was beim Vergleich zählt: der sichtbare Inhalt einer Funktion, nicht ihre Datei.
# Wird eine Karte umbenannt, wandert ihre Datei — der Vergleich hinge sonst an
# einem Dateinamen statt an dem, was dasteht.
FELDER = ("ziel", "regeln", "testen", "stand")
def laeuft(projekt: str) -> bool:
return (projekte.stand(projekt) / projekte.PLANUNG).is_dir()
def _kopie(projekt: str):
return projekte.stand(projekt) / projekte.PLANUNG
def starte(projekt: str):
"""Arbeitskopie anlegen. Ab hier zeigt `wurzel()` dorthin."""
ziel = _kopie(projekt)
if ziel.is_dir():
raise ValueError("Für dieses Projekt läuft schon eine Planung")
shutil.copytree(projekte.stand(projekt), ziel,
ignore=shutil.ignore_patterns(projekte.PLANUNG))
def brich_ab(projekt: str):
"""Planung verwerfen. Der Projektstand hat sie nie gesehen."""
shutil.rmtree(_kopie(projekt), ignore_errors=True)
def setze_um(projekt: str):
"""Die Kopie wird der neue Projektstand.
Erst die Kopie herausheben, dann den alten Stand räumen, dann die Kopie an
seine Stelle. Ein Absturz mitten im Räumen ließe sonst ein Projekt ohne Karten
zurück — und die Planung gleich mit.
"""
kopie = _kopie(projekt)
if not kopie.is_dir():
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner = projekte.stand(projekt)
daneben = ordner.parent / f"{ordner.name}.neu"
shutil.rmtree(daneben, ignore_errors=True)
kopie.rename(daneben)
for eintrag in ordner.iterdir():
shutil.rmtree(eintrag) if eintrag.is_dir() else eintrag.unlink()
for eintrag in daneben.iterdir():
eintrag.rename(ordner / eintrag.name)
daneben.rmdir()
def _karten(ordner) -> dict:
"""titel -> Sektionen. Verglichen wird über den Titel, nicht über die Datei."""
raus = {}
for datei in sorted(ordner.glob("*.md")):
s = parser.zerlege(datei.read_text())
raus[s["titel"]] = s
return raus
def _funktionstext(f: dict) -> str:
return "\n".join(str(f.get(feld) or "") for feld in FELDER)
def _funktionen(karte: dict) -> dict:
return {f["text"]: _funktionstext(f) for f in karte["funktionen"]}
def _kartentext(karte: dict) -> str:
return "\n".join((karte.get("art") or "", karte.get("ziel") or "",
karte.get("regeln") or ""))
def aenderungen(projekt: str) -> dict:
"""Was die Planung am Projektstand ändert — je Karte und je Funktion.
Ohne laufende Planung ist das leer. Die Übersicht zeigt dann nichts an, und
die Karten sehen aus wie immer.
"""
if not laeuft(projekt):
return {}
alt = _karten(projekte.stand(projekt))
neu = _karten(_kopie(projekt))
raus = {}
for titel in set(alt) | set(neu):
a, b = alt.get(titel), neu.get(titel)
if a is None:
raus[titel] = {"art": "neu", "funktionen": {
f["text"]: "neu" for f in b["funktionen"]}}
continue
if b is None:
raus[titel] = {"art": "weg", "funktionen": {
f["text"]: "weg" for f in a["funktionen"]}}
continue
fa, fb = _funktionen(a), _funktionen(b)
funktionen = {}
for name in set(fa) | set(fb):
if name not in fa:
funktionen[name] = "neu"
elif name not in fb:
funktionen[name] = "weg"
elif fa[name] != fb[name]:
funktionen[name] = "geaendert"
kopf = _kartentext(a) != _kartentext(b)
if funktionen or kopf:
raus[titel] = {"art": "geaendert", "funktionen": funktionen,
"kopf": kopf}
return raus
def _datei_zu(ordner, titel: str):
for datei in ordner.glob("*.md"):
if parser.zerlege(datei.read_text())["titel"] == titel:
return datei
return None
def zuruecksetzen(projekt: str, titel: str):
"""Eine Karte auf den Projektstand zurückholen.
Drei Fälle: neu dazugekommen (löschen), weggefallen (zurückholen), geändert
(überschreiben). Der Rest der Planung bleibt stehen.
"""
if not laeuft(projekt):
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner, kopie = projekte.stand(projekt), _kopie(projekt)
urspruenglich = _datei_zu(ordner, titel)
geplant = _datei_zu(kopie, titel)
if urspruenglich is None and geplant is None:
raise FileNotFoundError(f"Karte „{titel}“ nicht gefunden")
if urspruenglich is None:
geplant.unlink()
return
ziel = geplant or (kopie / urspruenglich.name)
ziel.write_text(urspruenglich.read_text())
def stand(projekt: str) -> dict:
return {"laeuft": laeuft(projekt), "aenderungen": aenderungen(projekt)}

View File

@@ -4,6 +4,10 @@ import shutil
import config
# Die Arbeitskopie einer laufenden Planung. Liegt im Projektordner und beginnt mit
# einem Punkt — damit taucht sie im Listing nicht als eigenes Projekt auf.
PLANUNG = ".planung"
def _pruefe_name(name: str):
if not name or not name.strip():
@@ -51,9 +55,24 @@ def loeschen(name: str):
shutil.rmtree(ordner)
def wurzel(name: str):
"""Projektordner für Feature-Zugriffe; FileNotFoundError bei unbekanntem Projekt."""
def stand(name: str):
"""Der Projektordner selbst — was das Projekt heute kann.
Nur wer den umgesetzten Stand meint, ruft das. Alles andere nimmt `wurzel()`.
"""
ordner = _ordner(name)
if not ordner.is_dir():
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
return ordner
def wurzel(name: str):
"""Der Ordner, in dem gerade gearbeitet wird.
Ohne offene Planung ist das der Projektordner. Läuft eine Planung, ist es ihre
Arbeitskopie — und zwar für alles: Karten, Gespräch, Gedächtnis. Ein
Schalter statt einer Fallunterscheidung in jeder Schreibfunktion.
"""
ordner = stand(name)
kopie = ordner / PLANUNG
return kopie if kopie.is_dir() else ordner

View File

@@ -18,10 +18,18 @@ from collections import Counter
import agents
import config
import features
import inhalt as inhaltspruefung
import quellcode
import ledger
import struktur
class LaeuftBereits(ValueError):
"""Eigener Typ, weil 409 genau eine Bedeutung haben muss.
Vorher war jeder Fehlstart ein 409 — auch ein fehlendes Quellverzeichnis. Das
Frontend liest 409 als „läuft schon" und hängt sich an einen Lauf, den es nicht
gibt. Ergebnis: „Keine Probleme gefunden" statt der Fehlermeldung.
"""
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
# "probleme": [...], "meldung": str}
_laeufe = {}
@@ -445,7 +453,17 @@ async def _neue_felder(system: str, aufgabe: dict, offen: list, erlaubt: tuple,
return vorschlaege
async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
def _stilschluessel(aufgabe: dict) -> str:
return ledger.schluessel(aufgabe["feature"], aufgabe["funktion"])
def _stilabdruck(aufgabe: dict, system: str) -> str:
"""Woran das Urteil hängt: der geprüfte Ausschnitt, der Katalog, die Frage.
Ändert sich eines davon, ist das gemerkte Urteil von gestern."""
return ledger.abdruck(system, inhalt(aufgabe))
async def _pruefe(stil: dict, aufgabe: dict, stand: dict = None) -> list[dict]:
"""Erst messen, dann umschreiben lassen, dann den Unterschied ausrechnen."""
bereich = BEREICHE[aufgabe["bereich"]]
system = SYSTEM.format(
@@ -455,6 +473,15 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
hinweis=bereich["hinweis"],
)
offen = struktur.befunde(aufgabe["bereich"], aufgabe["felder"])
# Gemessene Verstöße stehen fest — die dürfen nie aus dem Gedächtnis kommen.
# Sonst bliebe eine neu hinzugekommene Messregel an alten Karten wirkungslos.
schluessel, wie_gehabt = _stilschluessel(aufgabe), _stilabdruck(aufgabe, system)
if stand is not None and not offen:
gemerkt = stand["stil"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt:
return [dict(p) for p in gemerkt["probleme"]]
vorschlaege = await _neue_felder(system, aufgabe, offen, bereich["felder"],
katalogzeilen(stil[aufgabe["bereich"]]))
@@ -466,83 +493,33 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
for p in probleme:
p["feature"] = aufgabe["feature"]
p["funktion"] = aufgabe["funktion"]
if stand is not None:
stand["stil"][schluessel] = {"abdruck": wie_gehabt, "probleme": probleme}
return probleme
async def _stil_aufgaben(projekt: str):
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen."""
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen.
Auch hier ein Gedächtnis: 176 Anfragen je Lauf brachten den Anbieter zum
Drosseln, und die ausgefallenen Stellen meldeten dann nichts. Unveränderter
Ausschnitt bei unverändertem Katalog heißt: das Urteil von vorhin gilt noch.
"""
stil = {b: _stil_lesen(b) for b in BEREICHE}
liste = [a for f in features.pruefbar(projekt) for a in aufgaben(f)]
return liste, lambda a, _zaehl: _pruefe(stil, a)
def _quelle(projekt: str):
alle = features.pruefbar(projekt)
quelle = quellcode.dateien()
if not quelle:
raise ValueError("Kein Quelltext gefunden")
return alle, quelle
async def _feature_aufgaben(projekt: str):
"""Ein Lauf über alle Funktionen: steht hinter jeder auch Code? Und beschreibt
keine zwei Funktionen dieselbe Sache?
Ein Aufruf statt vieler, weil das Ergebnis je Karte gebündelt wird — zwei
Vorschläge für dieselbe Funktionsliste wären nicht unabhängig anwendbar.
Der Zähler läuft trotzdem je Funktion mit.
"""
alle, quelle = _quelle(projekt)
text = quellcode.als_text(quelle)
anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle)
async def abgleich(_, zaehl):
return await inhaltspruefung.pruefe_feature_im_code(
projekt, alle, quelle, text, zaehl, _melder(projekt))
return _platzhalter(anzahl), abgleich
async def _code_aufgaben(projekt: str):
"""Ein Lauf über alle Bedienstellen — Vollständigkeit ist eine Eigenschaft der
Menge, nicht der einzelnen Karte."""
alle, quelle = _quelle(projekt)
punkte = quellcode.interaktionen(quelle)
async def abgleich(_, zaehl):
return await inhaltspruefung.pruefe_code_im_feature(
projekt, alle, quelle, zaehl, _melder(projekt))
return _platzhalter(len(punkte)), abgleich
def _melder(projekt: str):
"""Sagt dem Lauf, was schiefging, ohne ihn abzubrechen.
Ein Sammellauf ist eine einzige Aufgabe. Flöge er wegen einer Stelle, wären
alle anderen Befunde weg — und der Balken stünde trotzdem auf 100 %. So bleibt
das Ergebnis erhalten und der Fehlstand steht daneben.
"""
def melde(text: str, fehler: int = 0):
if lauf := _laeufe.get(projekt):
lauf["meldung"] = text
lauf["fehler"] += fehler
return melde
def _platzhalter(anzahl: int) -> list:
"""Eine echte Aufgabe, der Rest nur für den Fortschrittsbalken."""
return [True] + [None] * max(0, anzahl - 1)
stand = ledger.lade(projekt)
stand["stil"] = ledger.behalte(stand["stil"], {_stilschluessel(a) for a in liste})
return (liste,
lambda a, _zaehl: _pruefe(stil, a, stand),
lambda: ledger.sichere(projekt, stand))
ARTEN = {
"stil": _stil_aufgaben,
"feature": _feature_aufgaben, # Feature im Code: was steht ohne Code da?
"code": _code_aufgaben, # Code im Feature: was fehlt auf den Karten?
}
async def _lauf(projekt: str, liste: list[dict], arbeite):
async def _lauf(projekt: str, liste: list[dict], arbeite, abschluss=None):
lauf = _laeufe[projekt]
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
@@ -570,6 +547,9 @@ async def _lauf(projekt: str, liste: list[dict], arbeite):
try:
await asyncio.gather(*(eine(a) for a in liste))
finally:
# Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt
if abschluss:
abschluss()
lauf["laeuft"] = False
@@ -578,10 +558,10 @@ async def starte(projekt: str, art: str = "stil") -> dict:
if art not in ARTEN:
raise ValueError(f"Unbekannte Prüfart „{art}“")
if status(projekt)["laeuft"]:
raise ValueError("Prüfung läuft bereits")
raise LaeuftBereits("Prüfung läuft bereits")
agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf
# Vorbereiten vor dem Task: ein Fehler darin wäre sonst unsichtbar
liste, arbeite = await ARTEN[art](projekt)
liste, arbeite, *rest = await ARTEN[art](projekt)
_laeufe[projekt] = {
"laeuft": True,
"art": art,
@@ -591,7 +571,7 @@ async def starte(projekt: str, art: str = "stil") -> dict:
"probleme": [],
"meldung": "",
}
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite))
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite, *rest))
_aufgaben.add(aufgabe)
aufgabe.add_done_callback(_aufgaben.discard)
return _laeufe[projekt]

View File

@@ -1,189 +0,0 @@
"""Der Quelltext als Prüfgrundlage.
Die Inhaltsprüfung vergleicht Karten mit dem, was das Programm wirklich kann.
Damit das nicht auf Behauptungen hinausläuft, muss jede Aussage über den Code
eine wörtlich zitierte Zeile mitbringen — `belegt()` prüft sie nach.
"""
import re
import config
# Was zum lauffähigen Programm gehört. Tests beschreiben keine Nutzerfunktionen.
MUSTER = (
"backend/*.py",
"frontend/index.html",
"frontend/src/*.js",
"frontend/src/**/*.js",
"frontend/src/**/*.vue",
)
AUSGENOMMEN = ("tests/", "/tests/", "node_modules/", "dist/")
def _passt(pfad: str) -> bool:
return not any(a in pfad for a in AUSGENOMMEN)
def dateien() -> dict:
"""Pfad relativ zur Projektwurzel -> Inhalt, in fester Reihenfolge."""
raus = {}
for muster in MUSTER:
for datei in sorted(config.PROJECT_ROOT.glob(muster)):
pfad = str(datei.relative_to(config.PROJECT_ROOT))
if datei.is_file() and _passt(pfad):
raus[pfad] = datei.read_text()
return dict(sorted(raus.items()))
def als_text(quelle: dict = None) -> str:
"""Alle Dateien am Stück, jede mit Überschrift — so kann das Modell zitieren."""
quelle = dateien() if quelle is None else quelle
return "\n\n".join(f"### {pfad}\n{text.rstrip()}" for pfad, text in quelle.items())
def _normal(zeile: str) -> str:
return re.sub(r"\s+", " ", zeile).strip()
def belegt(quelle: dict, pfad: str, schnipsel: str) -> bool:
"""Steht der Schnipsel wörtlich in der Datei?
Verglichen wird ohne Rücksicht auf Einrückung — Modelle geben sie ungenau
wieder. Alles andere muss stimmen, sonst ist der Beleg wertlos.
"""
text = quelle.get(pfad)
if text is None or not schnipsel.strip():
return False
gesucht = [_normal(z) for z in schnipsel.splitlines() if z.strip()]
if not gesucht:
return False
vorhanden = [_normal(z) for z in text.splitlines()]
# Zusammenhängend: eine mehrzeilige Angabe muss auch mehrzeilig dastehen
for i in range(len(vorhanden) - len(gesucht) + 1):
if vorhanden[i:i + len(gesucht)] == gesucht:
return True
return False
# Die Stellen, an denen ein Mensch etwas auslöst. Vorher hat ein Modell die
# Fähigkeiten frei aufgezählt — jede Runde andere, jede Runde anders benannt.
# Hier steht die Liste fest, weil sie aus dem Code fällt.
_EREIGNIS = re.compile(r'@(click|auxclick|contextmenu|keyup|keydown|blur|submit|change)'
r'[\w.]*\s*=\s*"([^"]*)"')
_MENUEPUNKT = re.compile(r"""\{\s*text:\s*['"](.+?)['"]\s*,\s*aktion:""")
_ROUTE = re.compile(r'@router\.(get|post|put|delete)\("([^"]+)"\)')
_LAUSCHER = re.compile(r'(window|document)\.addEventListener\(\s*[\'"](\w+)[\'"]')
_NAME = re.compile(r"[A-Za-zÄÖÜäöü_$][\w$]*")
# Rein technische Aufhänger: Speichern beim Verlassen, Abbrechen mit Escape.
# Sie tragen keine eigene Fähigkeit, sondern gehören zur Bedienung eines Feldes.
_UNINTERESSANT = {"fertig", "abbrechen", "blur", "waehle"}
# emit('auswaehlen') ist eine Fähigkeit, emit('abbrechen') nicht — es zählt das
# Ereignis, nicht der Aufruf. Vorher fiel mit „emit" auch das Auswählen weg.
_EMIT = re.compile(r"""emit\(\s*['"](\w+)['"]""")
_ZUWEISUNG = re.compile(r"^\s*[\w.]+\s*=[^=]")
def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str:
von, bis = max(0, nr - rand - 1), min(len(zeilen), nr + rand)
return "\n".join(zeilen[von:bis]).strip()
def _pyrumpf(zeilen: list, nr: int, hoechstens: int = 14) -> str:
"""Der eingerückte Block unter einer Zeile — für Python statt geschweifter Klammern."""
if nr > len(zeilen):
return "" # Dekorator ohne Rumpf, etwa am Dateiende
block = [zeilen[nr - 1]]
for weiter in zeilen[nr:nr + hoechstens]:
if weiter.strip() and not weiter[:1].isspace() and not weiter.startswith("@"):
break
block.append(weiter)
return "\n".join(block).rstrip()
def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str:
"""Die Umsetzung hinter einem Namen.
Das Markup allein sagt nur, dass ein Knopf etwas aufruft. Was er tut, steht im
Rumpf — ohne den musste der Prüfer raten und urteilte jede Runde anders.
"""
anfang = re.compile(rf"^\s*(async\s+)?function\s+{re.escape(name)}\s*\(|"
rf"^\s*const\s+{re.escape(name)}\s*=")
for i, zeile in enumerate(zeilen):
if not anfang.match(zeile):
continue
block = [zeile]
for weiter in zeilen[i + 1:i + hoechstens]:
block.append(weiter)
if weiter.startswith("}"):
break
return "\n".join(block)
return ""
def interaktionen(quelle: dict = None) -> list[dict]:
"""Auslösbare Stellen im Code, eine je Bedienhandlung.
Zusammengefasst nach dem aufgerufenen Namen: `@click="starte('ziel')"` steht
zwölfmal im Markup, ist aber eine Fähigkeit.
Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf.
Dazu die Backend-Routen. Sie standen zwischenzeitlich nicht drin, weil sie
doppelt gemeldet und dabei der falschen Karte zugeordnet wurden. Beides ist
jetzt aufgefangen: das Gedächtnis hält die Zuordnung fest, und der
Dublettenabgleich fängt zwei Funktionen für dieselbe Sache. Was hinter einem
Knopf liegt, ist der einzige Weg an Parser, Dateiverwaltung und Prüflogik —
ohne die Routen bliebe der ganze Rücken des Programms ungeprüft.
Reine Hilfsfunktionen bleiben draußen. Ein Kunde bestellt keine Funktion namens
`_slug`; sie wird über die Route erreicht, die ihn interessiert. Jedes
öffentliche Symbol aufzunehmen ergäbe wieder ein Code-Inventar statt einer
Auftragsliste.
"""
quelle = dateien() if quelle is None else quelle
raus, gesehen = [], set()
def merke(schluessel, art, pfad, nr, zeilen, beleg):
if schluessel in gesehen:
return None
gesehen.add(schluessel)
punkt = {"schluessel": schluessel, "art": art, "datei": pfad, "zeile": nr,
"beleg": beleg.strip(), "umfeld": _umfeld(zeilen, nr), "rumpf": ""}
raus.append(punkt)
return punkt
for pfad, text in quelle.items():
zeilen = text.splitlines()
for nr, zeile in enumerate(zeilen, 1):
for m in _MENUEPUNKT.finditer(zeile):
merke(f"menue:{m.group(1)}", "Menüpunkt", pfad, nr, zeilen, zeile)
for m in _LAUSCHER.finditer(zeile):
merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus",
pfad, nr, zeilen, zeile)
for m in _ROUTE.finditer(zeile):
punkt = merke(f"route:{m.group(1)} {m.group(2)}", "Schnittstelle",
pfad, nr, zeilen, zeile)
if punkt is not None:
punkt["rumpf"] = _pyrumpf(zeilen, nr + 1)
if not pfad.endswith(".vue"):
continue
for m in _EREIGNIS.finditer(zeile):
ausdruck = m.group(2)
if _ZUWEISUNG.match(ausdruck) or ausdruck.startswith("$"):
continue # Zuweisung oder Ereignis-Objekt, keine Fähigkeit
treffer = _EMIT.search(ausdruck) or _NAME.search(ausdruck)
if not treffer or treffer.group(1 if _EMIT.search(ausdruck) else 0) \
in _UNINTERESSANT:
continue
name = treffer.group(1) if _EMIT.search(ausdruck) else treffer.group(0)
punkt = merke(f"bedienen:{name}", "Bedienung", pfad, nr, zeilen, zeile)
if punkt is not None:
punkt["rumpf"] = _rumpf(zeilen, name)
return raus
def uebersicht(quelle: dict = None) -> str:
"""Kurze Dateiliste mit Zeilenzahl — für Prompts, die den Code nicht brauchen."""
quelle = dateien() if quelle is None else quelle
return "\n".join(f"- {p} ({len(t.splitlines())} Zeilen)" for p, t in quelle.items())

View File

@@ -4,8 +4,10 @@ hier wird auf 400/404/409 gemappt."""
from fastapi import APIRouter, HTTPException
import agents
import chat
import features
import projekte
import planung
import pruefung
router = APIRouter(prefix="/api")
@@ -55,9 +57,13 @@ async def projekt_loeschen(name: str):
@router.get("/projekte/{name}/features")
async def features_liste(name: str):
async def features_liste(name: str, sicht: str = "planung"):
"""`sicht=uebersicht` liest den Projektstand, sonst die Arbeitskopie.
Ohne laufende Planung sind beide dasselbe Verzeichnis.
"""
try:
return features.liste(name)
return features.liste(name, nur_stand=sicht == "uebersicht")
except (FileNotFoundError, ValueError) as e:
raise HTTPException(404, str(e))
@@ -89,10 +95,10 @@ async def pruefung_starten(name: str, body: dict | None = None):
return await pruefung.starte(name, art)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except agents.NichtKonfiguriert as e:
raise HTTPException(400, str(e)) # 409 heißt „läuft bereits" — hier fehlt die .env
except pruefung.LaeuftBereits as e:
raise HTTPException(409, str(e)) # 409 heißt genau das und sonst nichts
except ValueError as e:
raise HTTPException(409, str(e))
raise HTTPException(400, str(e))
@router.get("/projekte/{name}/pruefung")
@@ -100,6 +106,56 @@ async def pruefung_status(name: str):
return pruefung.status(name)
@router.get("/projekte/{name}/planung")
async def planung_stand(name: str):
try:
return planung.stand(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@router.post("/projekte/{name}/planung")
async def planung_starten(name: str):
try:
planung.starte(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(409, str(e))
return planung.stand(name)
@router.post("/projekte/{name}/planung/umsetzen")
async def planung_umsetzen(name: str):
try:
planung.setze_um(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(409, str(e))
return {"ok": True}
@router.delete("/projekte/{name}/planung")
async def planung_abbrechen(name: str):
try:
planung.brich_ab(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return {"ok": True}
@router.post("/projekte/{name}/planung/zuruecksetzen")
async def planung_zuruecksetzen(name: str, body: dict):
try:
planung.zuruecksetzen(name, (body or {}).get("titel") or "")
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(409, str(e))
return planung.stand(name)
@router.delete("/projekte/{name}/pruefung")
async def pruefung_beenden(name: str):
pruefung.beende(name)
@@ -113,3 +169,42 @@ async def feature_loeschen(name: str, id: str):
except (FileNotFoundError, ValueError) as e:
raise HTTPException(404, str(e))
return {"ok": True}
@router.put("/projekte/{name}/features/{id}/projekt")
async def feature_verschieben(name: str, id: str, body: dict):
try:
return features.verschieben(name, id, (body.get("ziel") or "").strip())
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.get("/projekte/{name}/chat")
async def chat_verlauf(name: str):
try:
return {"verlauf": chat.verlauf(name)}
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@router.post("/projekte/{name}/chat")
async def chat_frage(name: str, body: dict):
try:
return await chat.frage(name, body.get("text") or "")
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except agents.NichtKonfiguriert as e:
raise HTTPException(400, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.delete("/projekte/{name}/chat")
async def chat_leeren(name: str):
try:
chat.leeren(name)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
return {"ok": True}

View File

@@ -18,6 +18,9 @@ Gilt für jedes Feld.
Diese Vorgaben prüft der Code selbst, exakt und für jede Zeile. Halte dich beim
Schreiben daran — aber berufe dich nie darauf. Als `regel` sind sie nicht zulässig.
- Jede Funktion hat Ziel, Regeln oder Testschritte — nie nur einen Titel
- Titel einer Funktion endet auf ein Verb im Infinitiv
- Testschritte beschreiben genau einen Ablauf
- Kein Satzzeichen am Zeilenende, kein Punkt und kein Semikolon
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
- Keine Klammern und keine Schrägstriche

View File

@@ -3,6 +3,8 @@
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
- Eine Zeile je Schritt
- Genau ein Ablauf: handeln, handeln, Ergebnis
- Nach dem Ergebnis folgt keine Handlung mehr
- Schritte in Reihenfolge der Bedienung
- Kein Schritt der Bedienung fehlt
- Letzte Zeile beschreibt den Endzustand, keine Handlung
@@ -16,3 +18,12 @@ Richtig:
- Funktion entfernt
```
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)
Falsch, weil zwei Abläufe in einer Funktion stehen:
```
- Klick auf Miniaturbild
- Großansicht öffnet und schließt
- Option aus Großansicht auswählbar
```
Jede Zeile ist hier ein eigenes Ergebnis, kein Schritt eines Durchlaufs. Solche
Funktionen sind mehrere und gehören getrennt.

View File

@@ -4,6 +4,8 @@ Feld `titel`, wenn `funktion` gesetzt ist.
- Kurzsatz aus Objekt und Verb im Infinitiv
- Genau eine Tätigkeit
- Endet auf ein Verb im Infinitiv
- Nennt eine Handlung, keinen Bereich, keinen Zustand
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten) · `Galerie im Modal` (ein Bereich) · `Preis aktualisiert` (ein Zustand) · `In den Warenkorb` (kein Verb)

View File

@@ -29,8 +29,21 @@ BADGE = ("kern", "optional")
# „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*")
@@ -68,9 +81,15 @@ def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
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:
@@ -80,6 +99,30 @@ def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
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."""
@@ -94,13 +137,28 @@ def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
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
for zeile in _zeilen(feld, text or ""):
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

View File

@@ -1,60 +0,0 @@
import inhalt
import quellcode
def test_unformatierte_prompts_ohne_doppelklammern():
"""Was nicht durch .format() geht, darf keine Escape-Klammern tragen —
das Modell schrieb sie sonst wörtlich in die Antwort und das JSON war kaputt."""
for name in ("ABDECKUNG_SYSTEM",):
text = getattr(inhalt, name)
assert "{{" not in text and "}}" not in text, name
def test_formatierte_prompts_ueberstehen_format():
punkt = {"art": "Bedienung", "datei": "a.vue", "zeile": 1,
"umfeld": "x", "rumpf": ""}
fertig = inhalt.ABDECKUNG.format(funktionen="—", **punkt)
assert '{"abgedeckt": true' in fertig
assert "{{" not in fertig
def test_beleg_muss_woertlich_dastehen():
quelle = {"a.py": "def eins():\n return 1\n"}
assert quellcode.belegt(quelle, "a.py", "def eins():")
assert quellcode.belegt(quelle, "a.py", " def eins(): ") # Einrückung egal
assert quellcode.belegt(quelle, "a.py", "def eins():\nreturn 1")
assert not quellcode.belegt(quelle, "a.py", "def zwei():")
assert not quellcode.belegt(quelle, "b.py", "def eins():")
assert not quellcode.belegt(quelle, "a.py", "")
def test_interaktionen_sind_deterministisch():
quelle = {
"frontend/src/X.vue": (
'<button @click="starte(\'ziel\')">a</button>\n'
'<button @click="starte(\'regeln\')">b</button>\n'
'<div @contextmenu="menue">c</div>\n'
'<input @blur="fertig" @keydown.esc="abbrechen">\n'
'<span @click="offen = false">zu</span>\n'
),
"backend/routes.py": '@router.get("/projekte")\n@router.post("/projekte")\n',
"frontend/src/m.js": " { text: '+ Projekt erstellen', aktion: () => x() },\n",
}
schluessel = [p["schluessel"] for p in quellcode.interaktionen(quelle)]
# Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf
assert schluessel.count("bedienen:starte") == 1
assert "bedienen:menue" in schluessel
# Routen: der einzige Weg an Parser, Dateiverwaltung und Prüflogik
assert "route:get /projekte" in schluessel
assert "route:post /projekte" in schluessel
assert "menue:+ Projekt erstellen" in schluessel
# Technische Aufhänger und Zuweisungen sind keine Fähigkeiten
assert not any(s.startswith("bedienen:fertig") for s in schluessel)
assert not any(s.startswith("bedienen:abbrechen") for s in schluessel)
assert not any(s.startswith("bedienen:offen") for s in schluessel)
# Zweiter Aufruf liefert exakt dasselbe
assert schluessel == [p["schluessel"] for p in quellcode.interaktionen(quelle)]
def test_tests_sind_kein_quelltext():
assert not any("tests/" in p for p in quellcode.dateien())

View File

@@ -1,8 +1,8 @@
"""Das Gedächtnis der Inhaltsprüfungen: was gilt weiter, was wird neu gefragt.
"""Das Gedächtnis der Stilprüfung: was gilt weiter, was wird neu gefragt.
Die Prüfungen 2 und 3 fragen ein Modell. Dieselbe Frage kippt bei Grenzfällen auch
bei Temperatur 0 — vorher meldete Runde 1 einen Befund, Runde 2 keinen, Runde 3
wieder einen. Hier steht, unter welchen Bedingungen ein Urteil stehen bleibt.
Die Prüfung fragt ein Modell. Dieselbe Frage kippt bei Grenzfällen auch bei
Temperatur 0 — vorher meldete Runde 1 einen Befund, Runde 2 keinen, Runde 3 wieder
einen. Hier steht, unter welchen Bedingungen ein Urteil stehen bleibt.
"""
import json
@@ -11,7 +11,6 @@ import pytest
import agents
import features
import inhalt
import ledger
import projekte
@@ -33,9 +32,6 @@ def karte(projekt, titel, *funktionen):
return features.anlegen(projekt, text)
QUELLE = {"frontend/src/App.vue": '<button @click="starte">los</button>\n'}
class Zaehler:
"""Ersetzt das Modell und zählt mit, wie oft überhaupt gefragt wurde."""
@@ -82,431 +78,6 @@ def test_gedaechtnis_ist_kein_feature(projekt):
assert [f["sektionen"]["titel"] for f in features.liste(projekt)] == ["Eins"]
# ---------------------------------------------------------------- Prüfung 2
GEFUNDEN = json.dumps({"gefunden": True, "datei": "frontend/src/App.vue",
"beleg": '<button @click="starte">los</button>'})
async def test_beleg_wird_gemerkt_und_beim_zweiten_lauf_nicht_gefragt(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
erste_runde = modell.rufe
assert erste_runde >= len(inhalt.SUCHE_TEMPERATUREN)
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
assert modell.rufe == erste_runde # keine einzige neue Anfrage
async def test_beleg_verfaellt_wenn_die_zeile_verschwindet(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
vorher = modell.rufe
anders = {"frontend/src/App.vue": "<div>nichts mehr</div>\n"}
await inhalt.pruefe_feature_im_code(projekt, alle, anders, "### x\n<div>")
assert modell.rufe > vorher # der Beleg trägt nicht mehr, also neu fragen
async def test_geaenderte_funktion_wird_neu_gefragt(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
vorher = modell.rufe
datei = projekte.wurzel(projekt) / f"{features.liste(projekt)[0]['id']}.md"
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Inhalt"))
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
assert modell.rufe > vorher
async def test_fehlanzeige_gilt_nur_bei_unveraendertem_quelltext(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(json.dumps({"gefunden": False, "grund": "gibt es nicht"}))
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
erste = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
assert len(erste) == 1
vorher = modell.rufe
zweite = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
assert [p["vorschlag"] for p in zweite] == [p["vorschlag"] for p in erste]
assert modell.rufe == vorher
# Neuer Quelltext: eine Löschempfehlung muss frisch belegt werden
mehr = {**QUELLE, "backend/neu.py": "def starte():\n pass\n"}
await inhalt.pruefe_feature_im_code(projekt, alle, mehr, text)
assert modell.rufe > vorher
# ---------------------------------------------------------------- Dubletten
def punkt(schluessel="bedienen:starte", beleg='<button @click="starte">los</button>'):
return {"schluessel": schluessel, "art": "Bedienung",
"datei": "frontend/src/App.vue", "zeile": 1, "beleg": beleg,
"umfeld": beleg, "rumpf": ""}
async def test_gleicher_beleg_macht_zwei_funktionen_zu_kandidaten(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
monkeypatch.setattr(agents, "frage", Zaehler(json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": True})))
alle = features.liste(projekt)
stand = ledger.lade(projekt)
for f in alle:
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
"abdruck": "egal", "datei": "frontend/src/App.vue",
"beleg": '<button @click="starte">los</button>'}
gefunden = await inhalt.dubletten(alle, stand)
assert len(gefunden) == 1
# Gleich stark: der Titel entscheidet, damit der Vorschlag nicht springt
assert gefunden[0]["titel"] == "Etwas starten"
assert "Lauf beginnen" in gefunden[0]["grund"]
async def test_uneiniges_urteil_ist_keine_dublette_und_wird_gemerkt(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
def wechselhaft(nachricht, ruf):
return json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": ruf == 1})
modell = Zaehler(wechselhaft)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
stand = ledger.lade(projekt)
for f in alle:
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
"abdruck": "egal", "datei": "a.vue", "beleg": "x"}
assert await inhalt.dubletten(alle, stand) == []
vorher = modell.rufe
assert await inhalt.dubletten(alle, stand) == []
assert modell.rufe == vorher # Ablehnung wird gemerkt, nicht neu bezahlt
def test_gegenstueck_faellt_nicht_ueber_den_titel():
"""anlegen/entfernen und öffnen/schließen dürfen den Titelkanal nicht auslösen —
sonst kostet jedes Gegensatzpaar drei Anfragen für nichts."""
for a, b in (("Feature anlegen", "Feature entfernen"),
("Panel öffnen", "Panel schließen")):
assert inhalt._aehnlich(a, b) < inhalt.TITEL_KANAL
ENTFERNEN = ["Vollmodus öffnen", "RMB auf die Funktion",
'Klick auf "Funktion entfernen"', "Funktion entfernt"]
def test_umschriebene_dublette_wird_kandidat(projekt):
"""Anderer Titel, andere Karte, dieselbe Sache. Weder Titel noch Beleg fangen
das — nur der Inhaltskanal."""
karte(projekt, "Funktionen",
("Funktion entfernen", "x-Icon", ENTFERNEN),
("Funktion anlegen", "+-Icon", ["Vollmodus öffnen", "RMB auf die Karte",
'Klick auf "Funktion erstellen"',
"Funktion angelegt"]),
("Testschritte anpassen", "Ein Schritt je Zeile",
["Vollmodus öffnen", "LMB auf Testen", "Text ändern", "Schritte angepasst"]))
karte(projekt, "Features", ("Funktion löschen", "x-Icon", ENTFERNEN))
alle = inhalt._funktionen(features.liste(projekt))
paare = {frozenset((alle[a]["titel"], alle[b]["titel"])) for _, a, b, _ in
inhalt._kandidaten(alle, {})}
assert frozenset(("Funktion entfernen", "Funktion löschen")) in paare
assert frozenset(("Funktion anlegen", "Funktion entfernen")) not in paare
def test_floskeln_zaehlen_nicht_mit(projekt):
"""Was fast überall steht, unterscheidet nichts und fliegt aus dem Vergleich."""
schritte = ["Vollmodus öffnen", "Text ändern", "Angepasst"]
karte(projekt, "Eins",
*[(f"{feld} anpassen", "eine Regel", schritte)
for feld in ("Titel", "Ziel", "Regeln", "Testen", "Badge")])
kerne = inhalt._kerne(inhalt._funktionen(features.liste(projekt)))
gemeinsam = set.intersection(*kerne.values())
assert gemeinsam == set() # „vollmodus", „ändern", „angepasst" sind raus
assert all(k for k in kerne.values()) # jede Funktion behält ihr eigenes Wort
# ---------------------------------------------------------------- Prüfung 3
ABGEDECKT = json.dumps({"abgedeckt": True, "funktion": "Etwas starten"})
FEHLT = json.dumps({"abgedeckt": False, "karte": "Eins", "titel": "Neu bedienen"})
async def test_abdeckung_wird_gemerkt_solange_die_funktion_dasteht(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(ABGEDECKT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
vorher = modell.rufe
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
assert modell.rufe == vorher
# Funktion weg: die Abdeckung trägt nicht mehr
ohne = features.liste(projekt)
ohne[0]["sektionen"]["funktionen"] = []
await inhalt.code_im_feature(ohne, [punkt()], stand)
assert modell.rufe > vorher
async def test_geaenderte_codestelle_wird_neu_beurteilt(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(ABGEDECKT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
await inhalt.code_im_feature(alle, [punkt()], stand)
vorher = modell.rufe
await inhalt.code_im_feature(alle, [punkt(beleg="ganz anderer Code")], stand)
assert modell.rufe > vorher
async def test_fehlmeldung_bleibt_wortgleich_bis_die_funktion_da_ist(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(FEHLT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
erste = await inhalt.code_im_feature(alle, [punkt()], stand)
assert [x["titel"] for x in erste] == ["Neu bedienen"]
vorher = modell.rufe
zweite = await inhalt.code_im_feature(alle, [punkt()], stand)
assert zweite == erste
assert modell.rufe == vorher # kein Flackern, keine neue Anfrage
async def test_angenommener_vorschlag_loest_die_fehlmeldung_ab(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(FEHLT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand)
vorher = modell.rufe
# Der Nutzer nimmt den Vorschlag an — jetzt muss neu beurteilt werden
modell.antwort = ABGEDECKT
karte(projekt, "Zwei", ("Neu bedienen", "Knopf"))
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand) == []
assert modell.rufe > vorher
async def test_unentschiedenes_urteil_wird_festgehalten(projekt, monkeypatch):
"""Drei zu drei ist ein Münzwurf. Der darf einmal fallen, nicht jede Runde."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
def haelfte(nachricht, ruf):
return ABGEDECKT if ruf % 2 else FEHLT
modell = Zaehler(haelfte)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
vorher = modell.rufe
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
assert modell.rufe == vorher
async def test_verschwundene_stelle_raeumt_ihren_eintrag_weg(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
monkeypatch.setattr(agents, "frage", Zaehler(ABGEDECKT))
stand = ledger.lade(projekt)
alle = features.liste(projekt)
await inhalt.code_im_feature(alle, [punkt()], stand)
assert stand["abdeckung"]
await inhalt.code_im_feature(alle, [punkt(schluessel="bedienen:anderes")], stand)
assert "bedienen:starte" not in stand["abdeckung"]
def test_fehlende_stelle_landet_auch_auf_erfundener_karte(projekt):
"""Sechs Stimmen sagen „fehlt", nennen aber Karten, die es nicht gibt. Vorher
fiel der Befund lautlos weg."""
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
urteile = [{"abgedeckt": False, "karte": "Datenverwaltung", "titel": "x"}] * 4
assert inhalt._zielkarte(urteile, karten) is karten["Projekte"]
def test_erster_aufloesbarer_kartenname_gewinnt(projekt):
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
karte(projekt, "Darkmode", ("Darkmode umschalten", "Icon"))
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
urteile = ([{"abgedeckt": False, "karte": "Export", "titel": "x"}] * 3
+ [{"abgedeckt": False, "karte": "Darkmode", "titel": "x"}] * 2)
assert inhalt._zielkarte(urteile, karten) is karten["Darkmode"]
def test_ohne_karten_kein_ziel(projekt):
assert inhalt._zielkarte([{"abgedeckt": False, "karte": "X"}], {}) is None
def test_grund_trennt_dublette_von_fehlendem_code():
"""Beides heißt streichen — aber aus verschiedenen Gründen, und der Grund
entscheidet, ob man zustimmt."""
assert inhalt._wegfall_grund([{"art": "dublette"}]) == "1 Funktion doppelt"
assert inhalt._wegfall_grund([{"art": "ohne_code"}] * 2) \
== "2 Funktionen ohne eigenen Code"
assert inhalt._wegfall_grund([{"art": "dublette"}, {"art": "ohne_code"}]) \
== "1 Funktion doppelt, 1 Funktion ohne eigenen Code"
def test_halbe_datei_ueberschreibt_nicht_die_ganze(projekt, monkeypatch):
"""Absturz beim Schreiben: die alte Datei muss stehen bleiben."""
stand = ledger.lade(projekt)
stand["belege"]["a"] = {"abdruck": "x", "datei": "a.py", "beleg": "z"}
ledger.sichere(projekt, stand)
echt = ledger.json.dumps
def platzt(*a, **k):
raise MemoryError("mittendrin")
monkeypatch.setattr(ledger.json, "dumps", platzt)
with pytest.raises(MemoryError):
ledger.sichere(projekt, {"belege": {"b": {}}})
monkeypatch.setattr(ledger.json, "dumps", echt)
assert ledger.lade(projekt)["belege"] == stand["belege"]
async def test_ausgefallene_stimmen_merken_nichts(projekt, monkeypatch):
"""Fällt der Anbieter aus, darf keine Löschempfehlung im Gedächtnis landen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
async def tot(system, nachricht, temperatur=0.0):
raise RuntimeError("429")
monkeypatch.setattr(agents, "frage", tot)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.feature_im_code(features.liste(projekt), QUELLE, "x", stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [1] # laut gescheitert, nicht still
assert stand["ohne_code"] == {}
assert stand["belege"] == {}
async def test_ein_ausfall_rettet_die_anderen_urteile(projekt, monkeypatch):
"""Eine kaputte Stelle darf die bezahlten Urteile der anderen nicht wegwerfen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
async def launisch(system, nachricht, temperatur=0.0):
if "bedienen:kaputt" in nachricht:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", launisch)
stand = ledger.lade(projekt)
gemeldet = []
await inhalt.code_im_feature(
features.liste(projekt),
[punkt(), punkt(schluessel="bedienen:kaputt", beleg="bedienen:kaputt")],
stand, melde=lambda t, n=0: gemeldet.append(n))
assert gemeldet == [1]
assert "bedienen:starte" in stand["abdeckung"]
assert "bedienen:kaputt" not in stand["abdeckung"]
async def test_projekt_ohne_karten_stuerzt_nicht_ab(projekt, monkeypatch):
monkeypatch.setattr(agents, "frage", Zaehler(FEHLT))
stand = ledger.lade(projekt)
assert await inhalt.code_im_feature([], [punkt()], stand) == []
assert await inhalt.dubletten([], stand) == []
assert await inhalt.feature_im_code([], QUELLE, "x", stand) == []
async def test_feststehendes_urteil_braucht_keine_vollen_stimmen(projekt, monkeypatch):
"""Vier von sechs sagen „abgedeckt": die zwei fehlenden könnten die Schwelle
nicht mehr reißen. Daraus einen Fehlschlag zu machen wäre Verschwendung."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def zwei_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 2:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", zwei_platzen)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [] # kein Fehler: das Ergebnis stand fest
assert "bedienen:starte" in stand["abdeckung"]
async def test_offenes_urteil_bei_ausfall_wird_gemeldet(projekt, monkeypatch):
"""Vier Stimmen sagen „fehlt", zwei fehlen — mit ihnen wäre die Schwelle
erreicht. Das ist offen und muss laut scheitern, nicht still durchgehen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def zwei_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 2:
raise RuntimeError("429")
return FEHLT
monkeypatch.setattr(agents, "frage", zwei_platzen)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [1]
assert stand["abdeckung"] == {} and stand["ohne_karte"] == {}
async def test_zwei_ueberlebende_stimmen_urteilen_nicht(projekt, monkeypatch):
"""Rechnerisch könnten zwei Stimmen die Schwelle unerreichbar machen. Als
dauerhaft gemerktes Urteil ist das zu wenig."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def vier_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 4:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", vier_platzen)
stand = ledger.lade(projekt)
gemeldet = []
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n))
assert gemeldet == [1]
assert stand["abdeckung"] == {}
# ---------------------------------------------------------------- Vision
VISION = "<!-- titel -->\nVision\n<!-- art -->\nvision\n<!-- ziel -->\nSo soll es werden\n"
@@ -530,3 +101,65 @@ def test_keine_pruefung_fasst_die_vision_an(projekt):
assert len(features.liste(projekt)) == 2
titel = [f["sektionen"]["titel"] for f in features.pruefbar(projekt)]
assert titel == ["Eins"]
# ---------------------------------------------------------------- Prüfung 1
async def test_stilurteil_wird_gemerkt_und_nicht_neu_gefragt(projekt, monkeypatch):
"""176 Anfragen je Lauf brachten den Anbieter zum Drosseln. Unveränderter
Ausschnitt bei unverändertem Katalog heißt: das Urteil von vorhin gilt."""
import pruefung
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler('{"felder": {}}')
monkeypatch.setattr(agents, "frage", modell)
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
abschluss()
erste = modell.rufe
assert erste >= len(pruefung.TEMPERATUREN)
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
assert modell.rufe == erste # keine einzige neue Anfrage
async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
import pruefung
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler('{"felder": {}}')
monkeypatch.setattr(agents, "frage", modell)
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
abschluss()
vorher = modell.rufe
datei = projekte.wurzel(projekt) / f"{features.liste(projekt)[0]['id']}.md"
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Text"))
liste, arbeite, _ = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
assert modell.rufe > vorher
async def test_gemessener_verstoss_kommt_nie_aus_dem_gedaechtnis(projekt, monkeypatch):
"""Sonst bliebe eine neu hinzugekommene Messregel an alten Karten wirkungslos."""
import pruefung
karte(projekt, "Eins", ("Etwas starten", "Auch Passwort prüfen."))
modell = Zaehler('{"felder": {"regeln": {"neu": "- Passwort prüfen",'
' "grund": "Füllwort", "regel": "x"}}}')
monkeypatch.setattr(agents, "frage", modell)
for lauf in range(2):
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
gefunden = [p for a in liste for p in await arbeite(a, None)]
abschluss()
assert [p["vorschlag"] for p in gefunden] == ["Passwort prüfen"], lauf
assert modell.rufe > len(pruefung.TEMPERATUREN) # zweimal wirklich gefragt

View File

@@ -33,8 +33,10 @@ def test_alle_sektionen():
"ziel": "",
"regeln": "- Passwort nie loggen\n- Sperre nach 5 Fehlversuchen",
"testen": "- Formular öffnen\n- Daten eingeben",
"stand": "gebaut",
},
{"text": "Abmeldung", "ziel": "", "regeln": "", "testen": ""},
{"text": "Abmeldung", "ziel": "", "regeln": "", "testen": "",
"stand": "gebaut"},
]
@@ -114,3 +116,22 @@ def test_marker_mit_leerraum():
def test_titel_erste_nicht_leere_zeile():
s = zerlege("<!-- titel -->\n\nErste\nZweite\n")
assert s["titel"] == "Erste"
def test_stand_offen_wird_gelesen():
"""Bestellt, aber noch nicht gebaut — sonst klagt die Vorwärtsprüfung sie an."""
s = zerlege("<!-- titel -->\nX\n<!-- funktion -->\nA\n<!-- stand -->\noffen\n"
"<!-- funktion -->\nB\n")
assert [(f["text"], f["stand"]) for f in s["funktionen"]] == [("A", "offen"),
("B", "gebaut")]
assert s["sonstiges"] == "" # der Marker landet nicht im Rest
def test_unbekannter_stand_gilt_als_gebaut():
s = zerlege("<!-- titel -->\nX\n<!-- funktion -->\nA\n<!-- stand -->\nirgendwas\n")
assert s["funktionen"][0]["stand"] == "gebaut"
def test_stand_ohne_funktion_faellt_in_sonstiges():
s = zerlege("<!-- titel -->\nX\n<!-- stand -->\noffen\n")
assert s["sonstiges"] == "offen"

View File

@@ -0,0 +1,201 @@
"""Die Planung: eine Arbeitskopie, die den Projektstand nicht anfasst.
Geprüft wird die eine Zusage — solange die Planung läuft, bleibt die Übersicht,
was sie war. Und dass sie einen Neustart übersteht, weil sie auf der Platte liegt.
"""
import features
import parser
import planung
import projekte
import pytest
def karte(projekt: str, titel: str, *funktionen: str) -> str:
roh = f"<!-- titel -->\n{titel}\n" + "".join(
f"{'-' * 40}\n<!-- funktion -->\n{f}\n<!-- regeln -->\n- Etwas gilt\n"
for f in funktionen)
return features.anlegen(projekt, roh)["id"]
def titel(projekt: str) -> list:
return sorted(f["sektionen"]["titel"] for f in features.liste(projekt))
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("P")
karte("P", "Galerie", "Bild wählen")
karte("P", "Shop", "Karte kaufen")
return "P"
def test_ohne_planung_arbeitet_alles_im_projektordner(projekt):
assert not planung.laeuft(projekt)
assert projekte.wurzel(projekt) == projekte.stand(projekt)
assert planung.aenderungen(projekt) == {}
def test_planung_leitet_das_schreiben_um(projekt):
planung.starte(projekt)
assert projekte.wurzel(projekt) != projekte.stand(projekt)
karte(projekt, "Neu", "Etwas tun")
# Die Karte steht in der Kopie …
assert "Neu" in titel(projekt)
# … und nicht im Projektstand
assert not (projekte.stand(projekt) / "neu.md").exists()
def test_zweite_planung_wird_abgelehnt(projekt):
planung.starte(projekt)
with pytest.raises(ValueError):
planung.starte(projekt)
def test_planung_ueberlebt_einen_neustart(projekt):
planung.starte(projekt)
karte(projekt, "Neu", "Etwas tun")
# Nichts im Speicher — der nächste Prozess sieht nur die Platte
assert planung.laeuft(projekt)
assert "Neu" in planung.aenderungen(projekt)
def test_abbruch_laesst_den_projektstand_unberuehrt(projekt):
vorher = titel(projekt)
planung.starte(projekt)
karte(projekt, "Neu", "Etwas tun")
planung.brich_ab(projekt)
assert not planung.laeuft(projekt)
assert titel(projekt) == vorher
def test_umsetzen_macht_die_kopie_zum_stand(projekt):
planung.starte(projekt)
karte(projekt, "Neu", "Etwas tun")
planung.setze_um(projekt)
assert not planung.laeuft(projekt)
assert "Neu" in titel(projekt)
assert not (projekte.stand(projekt) / projekte.PLANUNG).exists()
def test_umsetzen_ohne_planung_ist_ein_fehler(projekt):
with pytest.raises(ValueError):
planung.setze_um(projekt)
# ---------------------------------------------------------------- Änderungen
def test_neue_karte_ist_neu(projekt):
planung.starte(projekt)
karte(projekt, "Neu", "Etwas tun")
a = planung.aenderungen(projekt)
assert a["Neu"]["art"] == "neu"
assert a["Neu"]["funktionen"] == {"Etwas tun": "neu"}
assert "Galerie" not in a
def test_entfernte_karte_ist_weg(projekt):
planung.starte(projekt)
features.loeschen(projekt, "shop")
a = planung.aenderungen(projekt)
assert a["Shop"]["art"] == "weg"
assert a["Shop"]["funktionen"] == {"Karte kaufen": "weg"}
def test_neue_funktion_wird_markiert(projekt):
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"],
f["roh"] + f"{'-' * 40}\n<!-- funktion -->\nBild löschen\n")
a = planung.aenderungen(projekt)
assert a["Galerie"]["art"] == "geaendert"
assert a["Galerie"]["funktionen"] == {"Bild löschen": "neu"}
def test_geaenderte_regel_wird_markiert(projekt):
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace("Etwas gilt", "Anderes gilt"))
assert planung.aenderungen(projekt)["Galerie"]["funktionen"] == {
"Bild wählen": "geaendert"}
def test_umbenannte_karte_ist_neu_und_weg(projekt):
"""Verglichen wird über den Titel. Eine Umbenennung ist zwei Änderungen."""
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.speichern(projekt, f["id"], f["roh"].replace("Shop", "Laden", 1))
a = planung.aenderungen(projekt)
assert a["Shop"]["art"] == "weg" and a["Laden"]["art"] == "neu"
def test_gleicher_inhalt_ist_keine_aenderung(projekt):
planung.starte(projekt)
f = features.liste(projekt)[0]
features.speichern(projekt, f["id"], f["roh"])
assert planung.aenderungen(projekt) == {}
# ---------------------------------------------------------------- Zurücksetzen
def test_zuruecksetzen_loescht_eine_neue_karte(projekt):
planung.starte(projekt)
karte(projekt, "Neu", "Etwas tun")
planung.zuruecksetzen(projekt, "Neu")
assert planung.aenderungen(projekt) == {}
assert "Neu" not in titel(projekt)
def test_zuruecksetzen_holt_eine_geloeschte_karte_zurueck(projekt):
planung.starte(projekt)
features.loeschen(projekt, "shop")
planung.zuruecksetzen(projekt, "Shop")
assert planung.aenderungen(projekt) == {}
assert "Shop" in titel(projekt)
def test_zuruecksetzen_stellt_den_alten_inhalt_her(projekt):
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace("Etwas gilt", "Anderes gilt"))
planung.zuruecksetzen(projekt, "Galerie")
assert planung.aenderungen(projekt) == {}
def test_zuruecksetzen_laesst_die_uebrigen_stehen(projekt):
planung.starte(projekt)
karte(projekt, "Eins", "A tun")
karte(projekt, "Zwei", "B tun")
planung.zuruecksetzen(projekt, "Eins")
assert set(planung.aenderungen(projekt)) == {"Zwei"}
def test_zuruecksetzen_ohne_planung_ist_ein_fehler(projekt):
with pytest.raises(ValueError):
planung.zuruecksetzen(projekt, "Galerie")
def test_unbekannte_karte_zuruecksetzen_meldet_es(projekt):
planung.starte(projekt)
with pytest.raises(FileNotFoundError):
planung.zuruecksetzen(projekt, "Gibt es nicht")
# ---------------------------------------------------------------- Nachbarschaft
def test_die_kopie_ist_kein_eigenes_projekt(projekt):
planung.starte(projekt)
assert [p["name"] for p in projekte.liste()] == ["P"]
def test_gespraech_und_gedaechtnis_wandern_mit(projekt):
"""Was während des Planens beurteilt wurde, gilt für den geplanten Stand."""
(projekte.stand(projekt) / ".pruefung.json").write_text('{"stil": {}}')
planung.starte(projekt)
assert (projekte.wurzel(projekt) / ".pruefung.json").is_file()
def test_die_kopie_kopiert_sich_nicht_selbst(projekt):
planung.starte(projekt)
assert not (projekte.wurzel(projekt) / projekte.PLANUNG).exists()

View File

@@ -197,8 +197,9 @@ async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch
async def luegt(system, inhalt, temperatur=0.0):
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
# „Anmelden" ist selbst ein gültiger Titel — sonst weist die Messung ihn ab
return ('{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",'
' "neu": "Anders", "grund": "x"},'
' "neu": "Einloggen", "grund": "x"},'
' "funktion": {"neu": "Erfunden", "grund": "y"}}}')
monkeypatch.setattr(agents, "frage", luegt)

View File

@@ -166,3 +166,96 @@ def test_gemessene_befunde_haengen_am_ausschnitt(datenordner):
text = pruefung.inhalt(karte, befunde)
assert "Gemessen, bitte neu schreiben:" in text
assert "Mehr als 3 Wörter" in text
def test_funktionstitel_braucht_ein_verb():
"""Ohne Verb steht dort ein Bereich oder ein Zustand — nichts Testbares."""
for titel in ("Galerie im Modal", "Preis aktualisiert", "In den Warenkorb",
"Modal tastaturgesteuert"):
assert _probleme(_pruefe("funktion", "titel", titel)) \
== ["Kein Verb im Infinitiv am Ende"], titel
def test_gute_funktionstitel_bleiben_unberuehrt():
for titel in ("Feature anlegen", "Anzeigemodus umschalten", "Option wählen",
"Nächsten Rückschritt anzeigen", "Karten prüfen", "Stand umschalten",
"Projekt aufrufen", "Bildergalerie bedienen", "PDF hochladen"):
assert _pruefe("funktion", "titel", titel) == [], titel
def test_kartentitel_darf_ohne_verb_stehen():
"""Eine Karte ist ein Gegenstand, keine Handlung."""
assert _pruefe("feature", "titel", "Karten prüfen") == []
assert _pruefe("feature", "titel", "Darkmode") == []
ABLAUF = "- Vollmodus öffnen\n- LMB auf Regeln\n- Text ändern\n- Regeln angepasst"
def test_ein_ablauf_geht_durch():
assert _pruefe("funktion", "testen", ABLAUF) == []
def test_zweiter_ablauf_wird_gemeldet():
"""So entstand „Bildergalerie bedienen": blättern, Miniatur, Großansicht in einem."""
zwei = ("- Zurück-Button klicken\n- Vorheriges Bild erscheint\n"
"- Miniaturbild klicken\n- Zugehöriges Bild erscheint")
b = _pruefe("funktion", "testen", zwei)
assert _probleme(b) == ["Zweiter Ablauf in einer Funktion"]
assert b[0]["original"] == "Miniaturbild klicken"
def test_nominalstil_ist_eine_handlung():
"""„Bestätigen im Modal" hat das Verb vorn — sonst gälte es als Ergebnis."""
text = ("- RMB auf Featurecard\n- Klick auf \"Feature entfernen\"\n"
"- Bestätigen im Modal\n- Feature entfernt")
assert _pruefe("funktion", "testen", text) == []
def test_mehrzahl_auf_en_ist_kein_verb():
"""Deutsche Mehrzahl endet auch auf -en — nur Anfang und Ende zählen."""
text = "- LMB auf Icon\n- Fehlerhafte Karten rot markiert"
assert _pruefe("funktion", "testen", text) == []
def test_zeilenfehler_haben_vorrang():
"""Erst die Zeilen in Ordnung bringen; zwei Vorschläge je Feld wären nicht
unabhängig anwendbar."""
text = "- Auch klicken.\n- Ergebnis da\n- Weiter klicken"
assert _probleme(_pruefe("funktion", "testen", text)) \
== ["Satzzeichen am Zeilenende, Füllwort „auch“"]
def test_funktion_ohne_jedes_feld_ist_leer():
"""Ein bloßer Titel beschreibt nichts — der Prüfer stülpt ihn über alles."""
b = struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
"regeln": "", "testen": ""})
assert _probleme(b) == ["Funktion ohne Ziel, Regeln und Testen"]
def test_ein_einziges_feld_genuegt():
for feld in ("ziel", "regeln", "testen"):
felder = {"titel": "Modal steuern", "ziel": "", "regeln": "", "testen": ""}
felder[feld] = "Etwas tun" if feld == "ziel" else "- Etwas tun"
assert struktur.befunde("funktion", felder) == [], feld
def test_einzelnes_feld_loest_nichts_aus():
"""Beim Prüfen eines Feldes sagt das Fehlen der anderen nichts."""
assert struktur.befunde("funktion", {"titel": "Modal steuern"}) == []
def test_kartenebene_darf_leer_sein():
assert struktur.befunde("feature", {"titel": "Darkmode", "badge": "kern",
"ziel": "", "regeln": ""}) == []
def test_platzhalter_ist_kein_inhalt():
"""`<testschritt>` erfüllt jede Formregel und sagt trotzdem nichts."""
assert struktur._umschreiben("funktion", "testen", "<testschritt>")
assert struktur._umschreiben("funktion", "regeln", "Noch offen ...")
assert struktur._umschreiben("funktion", "regeln", "TODO klären")
def test_kleiner_als_ist_kein_platzhalter():
assert not struktur._umschreiben("funktion", "regeln", "Wert 3 < 5")