This commit is contained in:
team3
2026-07-27 16:43:11 +02:00
parent 23c908534f
commit 7912eae715
14 changed files with 596 additions and 27 deletions

View File

@@ -77,6 +77,41 @@ einziger Vorschlag für das Feld — einzelne Zeilen daraus ergäben Dubletten o
Jede Anfrage bekommt nur ihren Ausschnitt der Karte und nur den Katalogteil, der für sie
gilt. Die Herkunft eines Problems stammt aus dem Code, nicht aus der Modellantwort.
## Mit dem Code abgleichen
Das ⌗-Icon prüft die andere Frage: bilden alle Karten zusammen ab, was das Programm
kann? Gesucht werden Lücken — Fähigkeiten ohne Funktion auf einer Karte.
```
1 Inventar quellcode.py Bedienstellen aus dem Code, deterministisch
2 Abdecken 6 Stimmen deckt eine bestehende Funktion die Stelle ab?
3 Ergänzen Code 5 von 6 sagen nein -> Vorschlag mit Fundort
```
**Inventar.** `interaktionen()` liest die Stellen aus, an denen ein Mensch etwas
auslöst: Ereignisse im Markup und Einträge der Kontextmenüs. Zusammengefasst nach
dem aufgerufenen Namen — `starte('ziel')` steht zwölfmal da und ist eine Fähigkeit.
Ein Modell hatte die Liste anfangs frei aufgezählt: jede Runde eine andere.
Nicht im Inventar: Backend-Routen (die Verdrahtung hinter den Knöpfen), Zuweisungen,
Speichern beim Verlassen, Abbrechen mit Escape.
**Abdecken.** Je Stelle eine Ja/Nein-Frage, mit dem Code-Umfeld, dem Rumpf der
Funktion dahinter und dem vollen Text aller Karten. Nur Titel reichten nicht — der
Richter musste raten und urteilte jede Runde anders.
**Schwelle.** Fünf von sechs Stimmen. Einstimmigkeit war zu scharf: dieselbe Frage
kippt auch bei Temperatur 0 gelegentlich, und ein Ausreißer löschte klare Funde.
Eindeutige Stellen antworten 6:0 oder 0:6, Grenzfälle etwa 3:3.
Jeder Vorschlag nennt seinen Fundort als `datei:zeile`. Die Gegenrichtung — „diese
Funktion hat keinen Code, weg damit" — ist bewusst nicht eingebaut: ein
Nichtvorhandensein lässt sich nicht belegen, und die Vorschläge zielten wiederholt
auf gültige Inhalte. Karten dürfen Dinge festhalten, die kein Knopf sind.
Gemessen an diesem Projekt: auf den unsynchronisierten Karten findet der Abgleich
die offenen Stellen, nach dem Übernehmen dreimal hintereinander nichts mehr.
Der Katalog liegt in `backend/stil/` — eine Datei je Feld, alphabetisch zusammengesetzt
(`allgemein` landet dadurch vorn). Das Präfix entscheidet, wer sie zu sehen bekommt:

View File

@@ -2,6 +2,8 @@
Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer
Meldung ab."""
import asyncio
import httpx
import config
@@ -10,6 +12,11 @@ ZEITLIMIT = 180.0
MAX_TOKENS = 8000
API_VERSION = "2023-06-01"
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
WIEDERHOLBAR = (429, 500, 502, 503, 504)
WIEDERHOLUNGEN = 4
PAUSE = 2.0
class NichtKonfiguriert(ValueError):
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
@@ -44,11 +51,19 @@ async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
"thinking": {"type": "disabled"},
}
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:
res = await client.post(
config.MINIMAX_URL,
headers={"x-api-key": config.MINIMAX_API_KEY, "anthropic-version": API_VERSION},
json=daten,
)
for versuch in range(WIEDERHOLUNGEN):
res = await client.post(
config.MINIMAX_URL,
headers={"x-api-key": config.MINIMAX_API_KEY,
"anthropic-version": API_VERSION},
json=daten,
)
if res.status_code not in WIEDERHOLBAR or versuch == WIEDERHOLUNGEN - 1:
break
# 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))
if res.status_code != 200:
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
antwort = res.json()

243
backend/inhalt.py Normal file
View File

@@ -0,0 +1,243 @@
"""Inhaltsprüfung: gleicht die Karten mit dem Quelltext ab.
Die Stilprüfung fragt „ist das gut geschrieben", diese hier fragt „stimmt das
überhaupt". Ziel ist, dass alle Karten zusammen alles abbilden, was das Programm
kann — und nichts, was es nicht kann.
In drei Schritten, weil ein einzelner nicht funktioniert hat:
1. **Inventar** — das Modell liest nur den Quelltext und listet die Fähigkeiten.
Ohne die Karten daneben, sonst bestätigt es bloß, was schon dasteht. Jede
Fähigkeit braucht eine wörtlich zitierte Codezeile; ohne Beleg fällt sie weg.
Mehrere Durchgänge werden vereinigt: hier zählt Vollständigkeit.
2. **Abdeckung** — je Fähigkeit eine kurze Frage: deckt eine bestehende Funktion
das ab? Eine Ja/Nein-Frage ist weit stabiler als offenes Suchen. Nur wenn alle
Stimmen „nein" sagen, entsteht ein Vorschlag.
Geprüft wird nur eine Richtung: was fehlt. Die Gegenrichtung — „diese Funktion hat
keinen Code, weg damit" — ist bewusst nicht eingebaut. Sie ließ sich nicht belegen
(ein Nichtvorhandensein lässt sich nicht zitieren), urteilte von Runde zu Runde
anders und schlug dabei vor, gültige Inhalte zu löschen. Karten dürfen außerdem
Dinge festhalten, die kein Knopf sind — Schreibregeln etwa.
"""
import asyncio
import difflib
import re
import agents
import config
import quellcode
# Ohne Drossel liefen alle Punkte gleichzeitig los und der Provider antwortete
# mit 429. Die ausgefallenen Stimmen sahen dann aus wie „alles abgedeckt".
_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 240 Anfragen je Lauf ist ein Ausrutscher normal, ein Abbruch wäre es nicht."""
for versuch in range(2):
try:
return _json(await _frage(system, nachricht, temperatur + 0.1 * versuch))
except ValueError:
continue
return None
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
ABDECKUNG = """An dieser Stelle kann eine Person etwas 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.
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.
Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen."""
# Achtung: dieser Text geht unformatiert als System-Prompt raus. Doppelte Klammern
# wären hier keine Escapes, sondern landeten wörtlich in der Antwort.
# Sechs Stimmen, fünf müssen „fehlt" sagen.
#
# Einstimmigkeit war zu scharf: gemessen kippt dieselbe Frage auch bei Temperatur 0
# gelegentlich, und eine einzelne Ausreißerstimme löschte damit auch klare Funde.
# Eindeutige Stellen antworten 6:0 oder 0:6, Grenzfälle etwa 3:3 — die Schwelle bei
# fünf trennt beides und übersteht einen Ausrutscher.
URTEIL_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
FEHLT_AB = 5
# Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
GLEICH = 0.75
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)
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. Ziel, Regeln und
Testschritte sagen es dagegen ausdrücklich.
"""
teile = []
for f in features:
s = f["sektionen"]
block = [f'## Karte: {s["titel"]}']
if s["ziel"]:
block.append(f'Ziel: {s["ziel"]}')
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)]
async def luecken(punkte: list, features: list) -> list:
"""Interaktionspunkte, die einstimmig von keiner Funktion abgedeckt sind."""
liste = _funktionsliste(features)
karten = {f["sektionen"]["titel"]: f for f in features}
getroffen = set() # Funktionen, die eine Stimme als Treffer genannt hat
async def eine(punkt):
urteile = await _abdeckung(punkt, liste)
for u in urteile:
if u.get("abgedeckt") and u.get("funktion"):
getroffen.add(str(u["funktion"]).strip())
if len(urteile) < len(URTEIL_TEMPERATUREN):
# Nicht verschweigen: eine ausgefallene Stimme sähe sonst 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")
if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB:
return None
ziele = [str(u.get("karte") or "").strip() for u in urteile]
karte = max(set(ziele), key=ziele.count)
treffer = karten.get(karte) or _naechste_karte(karte, karten)
if not treffer:
return None
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
if not titel:
return None
return {"feature": treffer["id"],
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
gefunden = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x]
luecken.getroffen = getroffen
# Zwei Punkte 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(x)
return raus
def _naechste_karte(name: str, karten: dict):
if not name:
return None
treffer = max(karten, key=lambda k: _aehnlich(name, k))
return karten[treffer] if _aehnlich(name, treffer) > 0.6 else None
def probleme(features: list, fehlend: list) -> list:
"""Aus den Lücken die Ergänzungen an den Funktionslisten ableiten."""
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
# die halbe Liste verschieben und aus einer Ergänzung einen Umbau machen.
# Angezeigt wird ohnehin alphabetisch.
neu = list(dict.fromkeys(ist + [x["titel"] for x in dazu]))
belege = " · ".join(x["beleg"] for x in dazu)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
_grund(dazu), belege):
p["feature"] = f["id"]
p["funktion"] = ""
raus.append(p)
return raus
def _grund(dazu: list) -> str:
return (f"{len(dazu)} Fähigkeit ohne Funktion" if len(dazu) == 1
else f"{len(dazu)} Fähigkeiten ohne Funktion")
async def pruefe(features: list, quelle: dict, quelltext: str):
"""Der ganze Abgleich: Interaktionspunkte, Lücken, Überhang."""
punkte = quellcode.interaktionen(quelle)
if not punkte:
raise RuntimeError("Keine Interaktionspunkte im Quelltext gefunden")
fehlend = await luecken(punkte, features)
return probleme(features, fehlend), punkte

View File

@@ -18,6 +18,8 @@ from collections import Counter
import agents
import config
import features
import inhalt as inhaltspruefung
import quellcode
import struktur
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
@@ -70,8 +72,9 @@ NACHBESSERN = """Dein Vorschlag verstößt weiter gegen messbare Vorgaben:
Schreibe die betroffenen Felder erneut. Gleiches Format wie zuvor."""
# Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn
ZEILENFELDER = ("regeln", "testen")
# Eine Quelle für „was ist eine Liste" — sonst zerlegt der eine Teil, was der
# andere als Block behandelt, und aus einer Ergänzung wird ein Komplettersatz.
ZEILENFELDER = struktur.ZEILENFELDER
# Felder, die leer bleiben dürfen; ein leerer Vorschlag streicht die Stelle
LEERBAR = ZEILENFELDER + ("ziel",)
# Alles, was es überhaupt gibt; je Bereich gilt eine engere Auswahl
@@ -178,7 +181,7 @@ def status(projekt: str) -> dict:
lauf = _laeufe.get(projekt)
if not lauf:
return {
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
"laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0,
"probleme": [], "meldung": "",
}
return lauf
@@ -450,7 +453,33 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
return probleme
async def _lauf(projekt: str, liste: list[dict], stil: dict):
async def _stil_aufgaben(projekt: str):
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen."""
stil = {b: _stil_lesen(b) for b in BEREICHE}
liste = [a for f in features.liste(projekt) for a in aufgaben(f)]
return liste, lambda a: _pruefe(stil, a)
async def _inhalt_aufgaben(projekt: str):
"""Ein Abgleich über alle Karten — Vollständigkeit ist eine Eigenschaft der
Menge, nicht der einzelnen Karte."""
alle = features.liste(projekt)
quelle = quellcode.dateien()
if not quelle:
raise ValueError("Kein Quelltext gefunden")
text = quellcode.als_text(quelle)
async def abgleich(_):
probleme, _inv = await inhaltspruefung.pruefe(alle, quelle, text)
return probleme
return [{"feature": "", "funktion": ""}], abgleich
ARTEN = {"stil": _stil_aufgaben, "inhalt": _inhalt_aufgaben}
async def _lauf(projekt: str, liste: list[dict], arbeite):
lauf = _laeufe[projekt]
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
@@ -459,7 +488,7 @@ async def _lauf(projekt: str, liste: list[dict], stil: dict):
if _laeufe.get(projekt) is not lauf:
return # verworfen (Panel geschlossen) — keine weitere Anfrage
try:
lauf["probleme"].extend(await _pruefe(stil, aufgabe))
lauf["probleme"].extend(await arbeite(aufgabe))
except Exception as e:
# Zählt trotzdem als erledigt, sonst hängt der Fortschritt.
# Die Meldung nennt die Zahl, damit „100 %" nicht vollständig vortäuscht.
@@ -475,22 +504,25 @@ async def _lauf(projekt: str, liste: list[dict], stil: dict):
lauf["laeuft"] = False
async def starte(projekt: str) -> dict:
async def starte(projekt: str, art: str = "stil") -> dict:
"""Startet einen Lauf im Hintergrund; ein laufender wird nicht verdoppelt."""
if art not in ARTEN:
raise ValueError(f"Unbekannte Prüfart „{art}“")
if status(projekt)["laeuft"]:
raise ValueError("Prüfung läuft bereits")
agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf
stil = {b: _stil_lesen(b) for b in BEREICHE} # ebenso: Fehler im Task wäre unsichtbar
liste = [a for f in features.liste(projekt) for a in aufgaben(f)]
# Vorbereiten vor dem Task: ein Fehler darin wäre sonst unsichtbar
liste, arbeite = await ARTEN[art](projekt)
_laeufe[projekt] = {
"laeuft": True,
"art": art,
"fertig": 0,
"gesamt": len(liste),
"fehler": 0,
"probleme": [],
"meldung": "",
}
aufgabe = asyncio.create_task(_lauf(projekt, liste, stil))
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite))
_aufgaben.add(aufgabe)
aufgabe.add_done_callback(_aufgaben.discard)
return _laeufe[projekt]

160
backend/quellcode.py Normal file
View File

@@ -0,0 +1,160 @@
"""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:""")
_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()
_ENDE = re.compile(r"^(function |const |async function |\S)")
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.
Backend-Routen stehen bewusst nicht drin. Sie sind die Verdrahtung hinter den
Knöpfen, nicht die Bedienung — als eigene Einträge erzeugten sie nur Meldungen
wie „Prüfungsstatus abrufen", die niemand auf einer Karte sehen will.
"""
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)
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

@@ -83,9 +83,10 @@ async def feature_speichern(name: str, id: str, body: dict):
@router.post("/projekte/{name}/pruefung")
async def pruefung_starten(name: str):
async def pruefung_starten(name: str, body: dict | None = None):
art = (body or {}).get("art") or "stil"
try:
return await pruefung.starte(name)
return await pruefung.starte(name, art)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except agents.NichtKonfiguriert as e:

View File

@@ -25,7 +25,9 @@ ZIEL_WOERTER = 12
# Abschließend. Genau diese Wörter, keine sinnverwandten.
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
BADGE = ("kern", "optional")
ZEILENFELDER = ("regeln", "testen")
# Felder, die als Liste geführt werden — je Zeile ein Eintrag.
# „funktionen" gehört der Inhaltsprüfung, hat aber dieselbe Gestalt.
ZEILENFELDER = ("regeln", "testen", "funktionen")
SATZENDE = ".;"
_KLAMMER = re.compile(r"[()/]")

View File

@@ -0,0 +1,58 @@
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
assert not any(s.startswith("route:") for s in schluessel) # Verdrahtung, keine Bedienung
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

@@ -123,7 +123,7 @@ async def test_parallel_begrenzt(projekt, monkeypatch):
async def test_status_ohne_lauf():
assert pruefung.status("Fremd") == {
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
"laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0,
"probleme": [], "meldung": "",
}

View File

@@ -87,7 +87,10 @@ onMounted(async () => {
@umbenannt="umbenannt"
>
<span v-if="aktivesProjekt" class="pruefen">
<button class="icon-knopf" title="Karten prüfen" @click="startePruefung(aktivesProjekt)">⌾</button>
<span class="knoepfe">
<button class="icon-knopf" title="Stil prüfen" @click="startePruefung(aktivesProjekt, 'stil')">⌾</button>
<button class="icon-knopf" title="Mit Code abgleichen" @click="startePruefung(aktivesProjekt, 'inhalt')">⌗</button>
</span>
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
</span>
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
@@ -137,13 +140,14 @@ main {
margin-top: 3rem;
text-align: center;
}
/* Fortschritt sitzt unter dem Prüf-Icon */
/* Fortschritt sitzt unter den Prüf-Icons */
.pruefen {
display: inline-flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.pruefen .knoepfe { display: flex; }
.fortschritt {
font-size: 0.65rem;
color: var(--dezent);

View File

@@ -41,8 +41,8 @@ export const featureSpeichern = (projekt, id, roh) =>
export const featureLoeschen = (projekt, id) =>
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
export const pruefungStarten = (projekt) =>
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST' })
export const pruefungStarten = (projekt, art = 'stil') =>
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST', body: { art } })
export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`)
export const pruefungBeenden = (projekt) =>
req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' })

View File

@@ -234,6 +234,18 @@ function anwenden(p) {
} else if (p.feld === 'badge') {
if (p.vorschlag !== 'kern' && p.vorschlag !== 'optional') return false
ziel.art = p.vorschlag
} else if (p.feld === 'funktionen') {
// Inhaltsprüfung: Einträge kommen dazu oder fallen weg. Bestehende Funktionen
// behalten dabei Ziel, Regeln und Testen — nur die Liste ändert sich.
const alt = s.value.funktionen
const titel = alt.map(f => f.text).join('\n')
const neu = p.ganzes_feld
? vorschlagsZeilen(p)
: (zeileAendern(titel, p) ?? '').split('\n').map(z => z.trim()).filter(Boolean)
if (p.ganzes_feld ? !neu.length : zeileAendern(titel, p) === null) return false
const bekannt = new Map(alt.map(f => [f.text, f]))
s.value.funktionen = neu.map(
t => bekannt.get(t) || { text: t, ziel: '', regeln: '', testen: '' })
} else if (p.feld === 'regeln' || p.feld === 'testen') {
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
if (p.ganzes_feld) {
@@ -363,8 +375,10 @@ function alleAblehnen() {
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
</section>
<section :class="{ leer: !s.funktionen.length }">
<section :class="{ leer: !s.funktionen.length && !problemeFeld('funktionen').length }">
<p class="gruppe">Funktionen</p>
<ProblemHinweis :probleme="problemeFeld('funktionen')" @annehmen="annehmen" @ablehnen="ablehnen" />
<ProblemHinweis :probleme="problemeAn('funktionen')" @annehmen="annehmen" @ablehnen="ablehnen" />
<div class="liste">
<div
v-for="(f, i) in s.funktionen"

View File

@@ -1,5 +1,7 @@
<script setup>
import { laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '../composables/pruefung.js'
import { art, laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '../composables/pruefung.js'
const UEBERSCHRIFT = { stil: 'Stilprüfung', inhalt: 'Abgleich mit Code' }
// Nur die erste Zeile — mehrzeilige Stellen liefen hier sonst zu einem Klumpen zusammen
function erste(text) {
@@ -21,7 +23,7 @@ function springe(p) {
<template>
<aside class="panel">
<header>
<p class="gruppe">Prüfung</p>
<p class="gruppe">{{ UEBERSCHRIFT[art] || 'Prüfung' }}</p>
<button class="icon-knopf" title="Schließen" @click="schliesse">✕</button>
</header>

View File

@@ -8,6 +8,7 @@ export const laeuft = ref(false)
export const fertig = ref(0)
export const gesamt = ref(0)
export const meldung = ref('')
export const art = ref('stil')
export const probleme = ref([])
const gesehen = new Set() // schon übernommene Probleme holt der nächste Abruf nicht erneut
@@ -76,6 +77,7 @@ function uebernehmen(stand) {
fertig.value = stand.fertig
gesamt.value = stand.gesamt
meldung.value = stand.meldung
if (stand.art) art.value = stand.art
for (const p of stand.probleme) {
const k = schluessel(p)
if (gesehen.has(k)) continue
@@ -107,14 +109,15 @@ function stoppeUhr() {
uhr = null
}
export async function starte(projekt) {
export async function starte(projekt, pruefart = 'stil') {
projektName = projekt
art.value = pruefart
offen.value = true
meldung.value = ''
gesehen.clear()
probleme.value = []
try {
uebernehmen(await pruefungStarten(projekt))
uebernehmen(await pruefungStarten(projekt, pruefart))
} catch (e) {
if (e.status !== 409) { // 409 = Lauf läuft schon; dann daran anhängen
meldung.value = e.message