update
This commit is contained in:
35
README.md
35
README.md
@@ -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
|
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.
|
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
|
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:
|
(`allgemein` landet dadurch vorn). Das Präfix entscheidet, wer sie zu sehen bekommt:
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer
|
Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer
|
||||||
Meldung ab."""
|
Meldung ab."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import config
|
import config
|
||||||
@@ -10,6 +12,11 @@ ZEITLIMIT = 180.0
|
|||||||
MAX_TOKENS = 8000
|
MAX_TOKENS = 8000
|
||||||
API_VERSION = "2023-06-01"
|
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):
|
class NichtKonfiguriert(ValueError):
|
||||||
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
|
"""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"},
|
"thinking": {"type": "disabled"},
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:
|
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:
|
||||||
res = await client.post(
|
for versuch in range(WIEDERHOLUNGEN):
|
||||||
config.MINIMAX_URL,
|
res = await client.post(
|
||||||
headers={"x-api-key": config.MINIMAX_API_KEY, "anthropic-version": API_VERSION},
|
config.MINIMAX_URL,
|
||||||
json=daten,
|
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:
|
if res.status_code != 200:
|
||||||
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
|
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
|
||||||
antwort = res.json()
|
antwort = res.json()
|
||||||
|
|||||||
243
backend/inhalt.py
Normal file
243
backend/inhalt.py
Normal 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
|
||||||
@@ -18,6 +18,8 @@ from collections import Counter
|
|||||||
import agents
|
import agents
|
||||||
import config
|
import config
|
||||||
import features
|
import features
|
||||||
|
import inhalt as inhaltspruefung
|
||||||
|
import quellcode
|
||||||
import struktur
|
import struktur
|
||||||
|
|
||||||
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
|
# 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."""
|
Schreibe die betroffenen Felder erneut. Gleiches Format wie zuvor."""
|
||||||
|
|
||||||
# Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn
|
# Eine Quelle für „was ist eine Liste" — sonst zerlegt der eine Teil, was der
|
||||||
ZEILENFELDER = ("regeln", "testen")
|
# 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
|
# Felder, die leer bleiben dürfen; ein leerer Vorschlag streicht die Stelle
|
||||||
LEERBAR = ZEILENFELDER + ("ziel",)
|
LEERBAR = ZEILENFELDER + ("ziel",)
|
||||||
# Alles, was es überhaupt gibt; je Bereich gilt eine engere Auswahl
|
# Alles, was es überhaupt gibt; je Bereich gilt eine engere Auswahl
|
||||||
@@ -178,7 +181,7 @@ def status(projekt: str) -> dict:
|
|||||||
lauf = _laeufe.get(projekt)
|
lauf = _laeufe.get(projekt)
|
||||||
if not lauf:
|
if not lauf:
|
||||||
return {
|
return {
|
||||||
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
|
"laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0,
|
||||||
"probleme": [], "meldung": "",
|
"probleme": [], "meldung": "",
|
||||||
}
|
}
|
||||||
return lauf
|
return lauf
|
||||||
@@ -450,7 +453,33 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
|||||||
return probleme
|
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]
|
lauf = _laeufe[projekt]
|
||||||
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
|
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:
|
if _laeufe.get(projekt) is not lauf:
|
||||||
return # verworfen (Panel geschlossen) — keine weitere Anfrage
|
return # verworfen (Panel geschlossen) — keine weitere Anfrage
|
||||||
try:
|
try:
|
||||||
lauf["probleme"].extend(await _pruefe(stil, aufgabe))
|
lauf["probleme"].extend(await arbeite(aufgabe))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Zählt trotzdem als erledigt, sonst hängt der Fortschritt.
|
# Zählt trotzdem als erledigt, sonst hängt der Fortschritt.
|
||||||
# Die Meldung nennt die Zahl, damit „100 %" nicht vollständig vortäuscht.
|
# 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
|
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."""
|
"""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"]:
|
if status(projekt)["laeuft"]:
|
||||||
raise ValueError("Prüfung läuft bereits")
|
raise ValueError("Prüfung läuft bereits")
|
||||||
agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf
|
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
|
# Vorbereiten vor dem Task: ein Fehler darin wäre sonst unsichtbar
|
||||||
liste = [a for f in features.liste(projekt) for a in aufgaben(f)]
|
liste, arbeite = await ARTEN[art](projekt)
|
||||||
_laeufe[projekt] = {
|
_laeufe[projekt] = {
|
||||||
"laeuft": True,
|
"laeuft": True,
|
||||||
|
"art": art,
|
||||||
"fertig": 0,
|
"fertig": 0,
|
||||||
"gesamt": len(liste),
|
"gesamt": len(liste),
|
||||||
"fehler": 0,
|
"fehler": 0,
|
||||||
"probleme": [],
|
"probleme": [],
|
||||||
"meldung": "",
|
"meldung": "",
|
||||||
}
|
}
|
||||||
aufgabe = asyncio.create_task(_lauf(projekt, liste, stil))
|
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite))
|
||||||
_aufgaben.add(aufgabe)
|
_aufgaben.add(aufgabe)
|
||||||
aufgabe.add_done_callback(_aufgaben.discard)
|
aufgabe.add_done_callback(_aufgaben.discard)
|
||||||
return _laeufe[projekt]
|
return _laeufe[projekt]
|
||||||
|
|||||||
160
backend/quellcode.py
Normal file
160
backend/quellcode.py
Normal 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())
|
||||||
@@ -83,9 +83,10 @@ async def feature_speichern(name: str, id: str, body: dict):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/projekte/{name}/pruefung")
|
@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:
|
try:
|
||||||
return await pruefung.starte(name)
|
return await pruefung.starte(name, art)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(404, str(e))
|
raise HTTPException(404, str(e))
|
||||||
except agents.NichtKonfiguriert as e:
|
except agents.NichtKonfiguriert as e:
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ ZIEL_WOERTER = 12
|
|||||||
# Abschließend. Genau diese Wörter, keine sinnverwandten.
|
# Abschließend. Genau diese Wörter, keine sinnverwandten.
|
||||||
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
|
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
|
||||||
BADGE = ("kern", "optional")
|
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 = ".;"
|
SATZENDE = ".;"
|
||||||
_KLAMMER = re.compile(r"[()/]")
|
_KLAMMER = re.compile(r"[()/]")
|
||||||
|
|||||||
58
backend/tests/test_inhalt.py
Normal file
58
backend/tests/test_inhalt.py
Normal 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())
|
||||||
@@ -123,7 +123,7 @@ async def test_parallel_begrenzt(projekt, monkeypatch):
|
|||||||
|
|
||||||
async def test_status_ohne_lauf():
|
async def test_status_ohne_lauf():
|
||||||
assert pruefung.status("Fremd") == {
|
assert pruefung.status("Fremd") == {
|
||||||
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
|
"laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0,
|
||||||
"probleme": [], "meldung": "",
|
"probleme": [], "meldung": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ onMounted(async () => {
|
|||||||
@umbenannt="umbenannt"
|
@umbenannt="umbenannt"
|
||||||
>
|
>
|
||||||
<span v-if="aktivesProjekt" class="pruefen">
|
<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 v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
|
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
|
||||||
@@ -137,13 +140,14 @@ main {
|
|||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
/* Fortschritt sitzt unter dem Prüf-Icon */
|
/* Fortschritt sitzt unter den Prüf-Icons */
|
||||||
.pruefen {
|
.pruefen {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
.pruefen .knoepfe { display: flex; }
|
||||||
.fortschritt {
|
.fortschritt {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--dezent);
|
color: var(--dezent);
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const featureSpeichern = (projekt, id, roh) =>
|
|||||||
export const featureLoeschen = (projekt, id) =>
|
export const featureLoeschen = (projekt, id) =>
|
||||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const pruefungStarten = (projekt) =>
|
export const pruefungStarten = (projekt, art = 'stil') =>
|
||||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST' })
|
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST', body: { art } })
|
||||||
export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`)
|
export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`)
|
||||||
export const pruefungBeenden = (projekt) =>
|
export const pruefungBeenden = (projekt) =>
|
||||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' })
|
req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' })
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ function anwenden(p) {
|
|||||||
} else if (p.feld === 'badge') {
|
} else if (p.feld === 'badge') {
|
||||||
if (p.vorschlag !== 'kern' && p.vorschlag !== 'optional') return false
|
if (p.vorschlag !== 'kern' && p.vorschlag !== 'optional') return false
|
||||||
ziel.art = p.vorschlag
|
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') {
|
} else if (p.feld === 'regeln' || p.feld === 'testen') {
|
||||||
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
|
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
|
||||||
if (p.ganzes_feld) {
|
if (p.ganzes_feld) {
|
||||||
@@ -363,8 +375,10 @@ function alleAblehnen() {
|
|||||||
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section :class="{ leer: !s.funktionen.length }">
|
<section :class="{ leer: !s.funktionen.length && !problemeFeld('funktionen').length }">
|
||||||
<p class="gruppe">Funktionen</p>
|
<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 class="liste">
|
||||||
<div
|
<div
|
||||||
v-for="(f, i) in s.funktionen"
|
v-for="(f, i) in s.funktionen"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<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
|
// Nur die erste Zeile — mehrzeilige Stellen liefen hier sonst zu einem Klumpen zusammen
|
||||||
function erste(text) {
|
function erste(text) {
|
||||||
@@ -21,7 +23,7 @@ function springe(p) {
|
|||||||
<template>
|
<template>
|
||||||
<aside class="panel">
|
<aside class="panel">
|
||||||
<header>
|
<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>
|
<button class="icon-knopf" title="Schließen" @click="schliesse">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const laeuft = ref(false)
|
|||||||
export const fertig = ref(0)
|
export const fertig = ref(0)
|
||||||
export const gesamt = ref(0)
|
export const gesamt = ref(0)
|
||||||
export const meldung = ref('')
|
export const meldung = ref('')
|
||||||
|
export const art = ref('stil')
|
||||||
export const probleme = ref([])
|
export const probleme = ref([])
|
||||||
|
|
||||||
const gesehen = new Set() // schon übernommene Probleme holt der nächste Abruf nicht erneut
|
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
|
fertig.value = stand.fertig
|
||||||
gesamt.value = stand.gesamt
|
gesamt.value = stand.gesamt
|
||||||
meldung.value = stand.meldung
|
meldung.value = stand.meldung
|
||||||
|
if (stand.art) art.value = stand.art
|
||||||
for (const p of stand.probleme) {
|
for (const p of stand.probleme) {
|
||||||
const k = schluessel(p)
|
const k = schluessel(p)
|
||||||
if (gesehen.has(k)) continue
|
if (gesehen.has(k)) continue
|
||||||
@@ -107,14 +109,15 @@ function stoppeUhr() {
|
|||||||
uhr = null
|
uhr = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function starte(projekt) {
|
export async function starte(projekt, pruefart = 'stil') {
|
||||||
projektName = projekt
|
projektName = projekt
|
||||||
|
art.value = pruefart
|
||||||
offen.value = true
|
offen.value = true
|
||||||
meldung.value = ''
|
meldung.value = ''
|
||||||
gesehen.clear()
|
gesehen.clear()
|
||||||
probleme.value = []
|
probleme.value = []
|
||||||
try {
|
try {
|
||||||
uebernehmen(await pruefungStarten(projekt))
|
uebernehmen(await pruefungStarten(projekt, pruefart))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status !== 409) { // 409 = Lauf läuft schon; dann daran anhängen
|
if (e.status !== 409) { // 409 = Lauf läuft schon; dann daran anhängen
|
||||||
meldung.value = e.message
|
meldung.value = e.message
|
||||||
|
|||||||
Reference in New Issue
Block a user