update
This commit is contained in:
58
README.md
58
README.md
@@ -9,13 +9,61 @@ Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards.
|
|||||||
|
|
||||||
## Karten prüfen
|
## Karten prüfen
|
||||||
|
|
||||||
Das ⌾-Icon in der Sidebar prüft alle Karten des aktiven Projekts gegen
|
Das ⌾-Icon in der Sidebar prüft alle Karten des aktiven Projekts gegen den Stil-Katalog.
|
||||||
`backend/stil.md` (eine Anfrage je Karte). Fehlerhafte Karten öffnen sich rot markiert
|
Fehlerhafte Karten öffnen sich rot markiert und zeigen unter jeder Stelle einen Vorschlag
|
||||||
und zeigen unter jeder Stelle einen Vorschlag mit ✓/✕; ✓ schreibt ihn in die Datei, die
|
mit ✓/✕; ✓ schreibt ihn in die Datei, die übrigen Vorschläge bleiben stehen. Unter dem
|
||||||
übrigen Vorschläge bleiben stehen. Rechts läuft ein Panel mit Fortschritt und Problemliste.
|
Icon steht der Fortschritt, rechts läuft ein Panel mit Problemliste.
|
||||||
|
|
||||||
|
Zwei Stufen je Prüfstelle. Was sich zählen lässt, zählt `struktur.py` — Wortzahl,
|
||||||
|
Satzzeichen am Zeilenende, Füllwörter, Klammern, Badge-Wert. Was es wegstreichen kann,
|
||||||
|
geht als fertiger Vorschlag durch, ganz ohne Anfrage. Der Rest hängt als Auftrag
|
||||||
|
„Gemessen, bitte neu schreiben" am Ausschnitt für das Modell. Das Modell darf diese
|
||||||
|
Klasse nicht selbst melden — sonst erfindet es Regeln, die im Katalog nicht stehen.
|
||||||
|
|
||||||
|
Höchstens ein gemessener Befund je Zeile: zwei Vorschläge für dieselbe Zeile wären
|
||||||
|
nicht unabhängig anwendbar.
|
||||||
|
|
||||||
|
Eine Anfrage je Prüfstelle, nicht je Karte:
|
||||||
|
|
||||||
|
```
|
||||||
|
Karte ──┬── Kartenebene titel · badge · ziel · regeln 1 Anfrage
|
||||||
|
├── Funktion 1 titel · ziel · regeln · testen 1 Anfrage
|
||||||
|
└── Funktion 2 … 1 Anfrage
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Vorschlag ersetzt seine Stelle eins zu eins. Was das bewirkt, sagt der Prüfer
|
||||||
|
nicht — es ergibt sich aus dem Vergleich:
|
||||||
|
|
||||||
|
```
|
||||||
|
eine Zeile schreibt die Stelle um
|
||||||
|
mehrere Zeilen ersetzen die Stelle durch alle davon; steht die alte
|
||||||
|
wieder vorn, kommt nur etwas dazu
|
||||||
|
leer streicht die Stelle
|
||||||
|
```
|
||||||
|
|
||||||
|
So muss das Modell keine Aktion auswählen. Es schreibt nur hin, was dort stehen soll.
|
||||||
|
Ohne umsetzbaren Vorschlag wird ein Problem verworfen statt leer angezeigt.
|
||||||
|
|
||||||
|
Vorschläge werden einzeln und in beliebiger Reihenfolge angenommen. Jeder muss
|
||||||
|
darum für sich allein anwendbar sein und selbst dem Katalog genügen.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.md beide Prüfer
|
||||||
|
feature-*.md nur die Kartenebene: titel, badge, ziel, regeln
|
||||||
|
funktion-*.md nur die Funktionen: titel, ziel, regeln, testen
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine neue Datei wird ohne Codeänderung mitgelesen. Ist der Ordner leer, bricht die
|
||||||
|
Prüfung ab statt ohne Vorgaben zu prüfen.
|
||||||
|
|
||||||
Zugang über `.env` (Vorlage: `.env.example`) — `MINIMAX_API_KEY`, `MINIMAX_MODELL`,
|
Zugang über `.env` (Vorlage: `.env.example`) — `MINIMAX_API_KEY`, `MINIMAX_MODELL`,
|
||||||
`MINIMAX_URL`, dazu `PRUEFUNG_PARALLEL` (gleichzeitig geprüfte Karten, Standard 10).
|
`MINIMAX_URL`, dazu `PRUEFUNG_PARALLEL` (gleichzeitig laufende Anfragen, Standard 10).
|
||||||
Fehlt einer der Zugangswerte, bricht die Prüfung mit sichtbarer Meldung ab;
|
Fehlt einer der Zugangswerte, bricht die Prüfung mit sichtbarer Meldung ab;
|
||||||
es wird nie still auf ein anderes Modell ausgewichen. Ergebnisse liegen nur im
|
es wird nie still auf ein anderes Modell ausgewichen. Ergebnisse liegen nur im
|
||||||
Serverspeicher und überleben keinen Neustart.
|
Serverspeicher und überleben keinen Neustart.
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ MAX_TOKENS = 8000
|
|||||||
API_VERSION = "2023-06-01"
|
API_VERSION = "2023-06-01"
|
||||||
|
|
||||||
|
|
||||||
|
class NichtKonfiguriert(ValueError):
|
||||||
|
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
|
||||||
|
„Prüfung läuft bereits" verwechselt — beides wäre sonst ein ValueError."""
|
||||||
|
|
||||||
|
|
||||||
def pruefe_konfiguration():
|
def pruefe_konfiguration():
|
||||||
fehlend = [
|
fehlend = [
|
||||||
name
|
name
|
||||||
@@ -22,7 +27,7 @@ def pruefe_konfiguration():
|
|||||||
if not wert
|
if not wert
|
||||||
]
|
]
|
||||||
if fehlend:
|
if fehlend:
|
||||||
raise ValueError(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
|
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
|
||||||
|
|
||||||
|
|
||||||
async def frage(system: str, inhalt: str) -> str:
|
async def frage(system: str, inhalt: str) -> str:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DATEN_DIR = PROJECT_ROOT / "daten"
|
DATEN_DIR = PROJECT_ROOT / "daten"
|
||||||
STIL_DATEI = Path(__file__).resolve().parent / "stil.md"
|
# Ein Abschnitt je Datei; alphabetisch gelesen, „allgemein" landet dadurch vorn.
|
||||||
|
# Neue Datei anlegen genügt — der Katalog nimmt sie ohne Codeänderung auf.
|
||||||
|
STIL_DIR = Path(__file__).resolve().parent / "stil"
|
||||||
|
|
||||||
|
|
||||||
def _lade_env():
|
def _lade_env():
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Prüft die Karten eines Projekts gegen den Stil-Katalog.
|
"""Prüft die Karten eines Projekts gegen den Stil-Katalog.
|
||||||
|
|
||||||
Ein Lauf je Projekt, Zustand nur im Speicher — er überlebt keinen Serverneustart.
|
Ein Lauf je Projekt, Zustand nur im Speicher — er überlebt keinen Serverneustart.
|
||||||
Je Karte eine Anfrage, höchstens PRUEFUNG_PARALLEL gleichzeitig; Probleme erscheinen,
|
Eine Anfrage je Prüfstelle: einmal die Kartenebene (titel, badge, ziel, regeln) und
|
||||||
sobald die jeweilige Karte geprüft ist.
|
dazu je Funktion einmal (titel, ziel, regeln, testen). Jede Anfrage bekommt nur den
|
||||||
|
Katalogteil, der für sie gilt, und nur ihren Ausschnitt der Karte — das hält den
|
||||||
|
Prompt kurz und die Zuordnung eindeutig.
|
||||||
|
Höchstens PRUEFUNG_PARALLEL gleichzeitig; Probleme erscheinen, sobald ihre Stelle
|
||||||
|
geprüft ist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -12,85 +16,291 @@ import re
|
|||||||
import agents
|
import agents
|
||||||
import config
|
import config
|
||||||
import features
|
import features
|
||||||
|
import struktur
|
||||||
|
|
||||||
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int,
|
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
|
||||||
# "probleme": [...], "meldung": str}
|
# "probleme": [...], "meldung": str}
|
||||||
_laeufe = {}
|
_laeufe = {}
|
||||||
|
|
||||||
SYSTEM = """Du prüfst Karten einer Feature-Verwaltung gegen einen Stil-Katalog.
|
# create_task gibt die Aufgabe nur schwach an die Schleife weiter — ohne eigene
|
||||||
|
# Referenz kann der Lauf mitten drin weggesammelt werden.
|
||||||
|
_aufgaben = set()
|
||||||
|
|
||||||
|
SYSTEM = """Du prüfst {gegenstand} gegen einen Stil-Katalog.
|
||||||
|
|
||||||
{stil}
|
{stil}
|
||||||
|
|
||||||
Du bekommst eine Karte als Markdown. Melde jede Abweichung vom Katalog.
|
Melde jede Abweichung vom Katalog. Antworte ausschließlich mit JSON:
|
||||||
Antworte ausschließlich mit JSON:
|
{{"probleme": [{{"feld": "...", "original": "...",
|
||||||
{{"probleme": [{{"feld": "...", "funktion": "...", "original": "...",
|
|
||||||
"problem": "...", "vorschlag": "..."}}]}}
|
"problem": "...", "vorschlag": "..."}}]}}
|
||||||
|
|
||||||
- feld: titel, ziel, regeln oder testen
|
- feld: {felder}
|
||||||
- funktion: Titel der Funktion, zu der die Stelle gehört; "" für die Karte selbst
|
- original: die betroffene Stelle wortwörtlich; bei regeln und testen genau eine
|
||||||
- original: der fehlerhafte Text wortwörtlich; bei regeln und testen genau eine Zeile ohne führendes "- "
|
Zeile ohne führendes "- "
|
||||||
- problem: die verletzte Vorgabe in höchstens 8 Wörtern
|
- problem: die verletzte Vorgabe in höchstens 8 Wörtern
|
||||||
- vorschlag: der korrigierte Text, sonst nichts
|
- vorschlag: der vollständige neue Text für diese Stelle
|
||||||
|
|
||||||
|
vorschlag ersetzt original eins zu eins:
|
||||||
|
- eine Zeile schreibt die Stelle um
|
||||||
|
- mehrere Zeilen setzen alle davon an die Stelle
|
||||||
|
- leer streicht die Stelle
|
||||||
|
|
||||||
|
Mehrere Zeilen gibt es nur bei regeln und testen. Leer ist nur bei regeln, testen
|
||||||
|
und ziel erlaubt.
|
||||||
|
|
||||||
|
Jedes Problem braucht einen Vorschlag. Weißt du keinen konkreten Ersatz, melde nichts.
|
||||||
|
|
||||||
|
Wortzahl, Satzzeichen am Zeilenende und Füllwörter misst das System selbst und
|
||||||
|
meldet sie schon. Melde sie nicht noch einmal. Steht unten ein Abschnitt „Gemessen",
|
||||||
|
schreib genau diese Stellen neu.
|
||||||
|
|
||||||
|
Der Nutzer nimmt jeden Vorschlag einzeln an oder ab, in beliebiger Reihenfolge.
|
||||||
|
Darum:
|
||||||
|
- Jeder Vorschlag muss für sich allein anwendbar sein, ohne einen anderen
|
||||||
|
- Begründe nie mit dem, was ein anderer Vorschlag ändern würde
|
||||||
|
- Der Vorschlag selbst muss den Katalog erfüllen — sonst meldest du ihn beim
|
||||||
|
nächsten Lauf wieder
|
||||||
|
|
||||||
|
Nicht mitgeschickte Felder sind leer und werden nicht gemeldet.
|
||||||
|
{hinweis}
|
||||||
Keine Probleme gefunden: {{"probleme": []}}
|
Keine Probleme gefunden: {{"probleme": []}}
|
||||||
Erfinde nichts und melde nur, was der Katalog verlangt."""
|
Erfinde nichts und melde nur, was der Katalog verlangt.
|
||||||
|
Der Katalog ist abschließend — leite keine weiteren Vorgaben daraus ab."""
|
||||||
|
|
||||||
|
# Nur hier ist der Text eine Liste — mehrzeilige Vorschläge ergeben nur dort Sinn
|
||||||
|
ZEILENFELDER = ("regeln", "testen")
|
||||||
|
# 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
|
||||||
|
ALLE_FELDER = ("titel", "badge", "ziel", "regeln", "testen")
|
||||||
|
|
||||||
|
# Was der jeweilige Prüfer sieht und melden darf. Der Bereichsname ist zugleich das
|
||||||
|
# Dateipräfix im Stil-Ordner — „feature" liest allgemein-* und feature-*.
|
||||||
|
BEREICHE = {
|
||||||
|
"feature": {
|
||||||
|
"gegenstand": "die Kartenebene einer Feature-Karte",
|
||||||
|
"felder": ("titel", "badge", "ziel", "regeln"),
|
||||||
|
# Ohne die Funktionstitel kann niemand beurteilen, ob die Karte kern ist
|
||||||
|
"hinweis": (
|
||||||
|
"Der Block funktionen ist nur Kontext für die Badge-Einstufung."
|
||||||
|
" Melde nichts daraus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"funktion": {
|
||||||
|
"gegenstand": "eine einzelne Funktion einer Feature-Karte",
|
||||||
|
"felder": ("titel", "ziel", "regeln", "testen"),
|
||||||
|
"hinweis": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _felder_text(felder: tuple) -> str:
|
||||||
|
return f"{', '.join(felder[:-1])} oder {felder[-1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _stil_lesen(bereich: str) -> str:
|
||||||
|
"""Katalog für einen Bereich: die allgemein-Dateien plus die des Bereichs.
|
||||||
|
Leerer Ordner heißt Fehlbedienung — dann lieber abbrechen als blind prüfen."""
|
||||||
|
|
||||||
|
def gehoert(datei, gruppe):
|
||||||
|
return datei.stem == gruppe or datei.stem.startswith(f"{gruppe}-")
|
||||||
|
|
||||||
|
dateien = sorted(
|
||||||
|
d for d in config.STIL_DIR.glob("*.md")
|
||||||
|
if gehoert(d, "allgemein") or gehoert(d, bereich)
|
||||||
|
)
|
||||||
|
if not dateien:
|
||||||
|
raise ValueError(f"Kein Stil-Katalog für „{bereich}“ in {config.STIL_DIR}")
|
||||||
|
return "\n\n".join(d.read_text().strip() for d in dateien)
|
||||||
|
|
||||||
|
|
||||||
|
def _block(marker: str, text: str) -> str:
|
||||||
|
"""Leere Felder gar nicht erst mitschicken — sonst meldet der Prüfer sie als fehlend."""
|
||||||
|
return f"<!-- {marker} -->\n{text}\n" if text.strip() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def aufgaben(feature: dict) -> list[dict]:
|
||||||
|
"""Eine Prüfstelle für die Kartenebene, dazu je Funktion eine.
|
||||||
|
`felder` ist das, was geprüft wird; `kontext` geht nur zur Einordnung mit."""
|
||||||
|
s = feature["sektionen"]
|
||||||
|
liste = [{
|
||||||
|
"feature": feature["id"],
|
||||||
|
"funktion": "",
|
||||||
|
"bereich": "feature",
|
||||||
|
"felder": {
|
||||||
|
"titel": s["titel"],
|
||||||
|
"badge": s["art"],
|
||||||
|
"ziel": s["ziel"],
|
||||||
|
"regeln": s["regeln"],
|
||||||
|
},
|
||||||
|
"kontext": {"funktionen": "\n".join(f"- {f['text']}" for f in s["funktionen"])},
|
||||||
|
}]
|
||||||
|
for f in s["funktionen"]:
|
||||||
|
liste.append({
|
||||||
|
"feature": feature["id"],
|
||||||
|
"funktion": f["text"],
|
||||||
|
"bereich": "funktion",
|
||||||
|
"felder": {
|
||||||
|
"titel": f["text"],
|
||||||
|
"ziel": f["ziel"],
|
||||||
|
"regeln": f["regeln"],
|
||||||
|
"testen": f["testen"],
|
||||||
|
},
|
||||||
|
"kontext": {},
|
||||||
|
})
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def inhalt(aufgabe: dict, offen: list[dict] = ()) -> str:
|
||||||
|
"""Der Ausschnitt für das Modell. `offen` sind gemessene Verstöße, für die der
|
||||||
|
Code keinen Ersatz kennt — sie hängen als Auftrag hinten dran."""
|
||||||
|
text = "".join(
|
||||||
|
_block(k, v) for k, v in {**aufgabe["felder"], **aufgabe["kontext"]}.items()
|
||||||
|
)
|
||||||
|
if offen:
|
||||||
|
text += "\nGemessen, bitte neu schreiben:\n"
|
||||||
|
text += "\n".join(struktur.auftrag(b) for b in offen) + "\n"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def status(projekt: str) -> dict:
|
def status(projekt: str) -> dict:
|
||||||
lauf = _laeufe.get(projekt)
|
lauf = _laeufe.get(projekt)
|
||||||
if not lauf:
|
if not lauf:
|
||||||
return {"laeuft": False, "fertig": 0, "gesamt": 0, "probleme": [], "meldung": ""}
|
return {
|
||||||
|
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
|
||||||
|
"probleme": [], "meldung": "",
|
||||||
|
}
|
||||||
return lauf
|
return lauf
|
||||||
|
|
||||||
|
|
||||||
def _ohne_strich(text: str) -> str:
|
def _ohne_strich(text: str) -> str:
|
||||||
return re.sub(r"^[-*]\s*", "", (text or "").strip())
|
"""Listenzeilen kommen mal mit, mal ohne „- " zurück — je Zeile vereinheitlicht,
|
||||||
|
damit die Anzeige die Stelle wiederfindet."""
|
||||||
|
zeilen = (text or "").strip().splitlines()
|
||||||
|
return "\n".join(re.sub(r"^[-*]\s*", "", z.strip()) for z in zeilen)
|
||||||
|
|
||||||
|
|
||||||
def _antwort_lesen(text: str) -> list[dict]:
|
_STRUKTUR = set(',:}]')
|
||||||
|
|
||||||
|
|
||||||
|
def _quotes_maskieren(roh: str) -> str:
|
||||||
|
"""Karteninhalt enthält Anführungszeichen (`Klick auf "Feature entfernen"`).
|
||||||
|
Das Modell gibt ihn wortwörtlich zurück und maskiert sie oft nicht — dann ist das
|
||||||
|
JSON kaputt. Ein Zeichen schließt den String nur, wenn danach JSON-Struktur folgt."""
|
||||||
|
aus = []
|
||||||
|
im_string = False
|
||||||
|
i = 0
|
||||||
|
while i < len(roh):
|
||||||
|
z = roh[i]
|
||||||
|
if im_string and z == "\\" and i + 1 < len(roh):
|
||||||
|
aus.append(roh[i:i + 2]) # bestehende Escape-Sequenz unverändert
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if z == '"':
|
||||||
|
if not im_string:
|
||||||
|
im_string = True
|
||||||
|
elif roh[i + 1:].lstrip()[:1] in _STRUKTUR or not roh[i + 1:].strip():
|
||||||
|
im_string = False
|
||||||
|
else:
|
||||||
|
aus.append("\\")
|
||||||
|
aus.append(z)
|
||||||
|
i += 1
|
||||||
|
return "".join(aus)
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort_lesen(text: str, felder: tuple = ALLE_FELDER) -> list[dict]:
|
||||||
"""Holt das JSON aus der Antwort, auch wenn es in ```-Zäunen steckt.
|
"""Holt das JSON aus der Antwort, auch wenn es in ```-Zäunen steckt.
|
||||||
Listenzeilen kommen mal mit, mal ohne „- " zurück — hier vereinheitlicht,
|
Listenzeilen kommen mal mit, mal ohne „- " zurück — hier vereinheitlicht,
|
||||||
damit die Anzeige die Stelle wiederfindet."""
|
damit die Anzeige die Stelle wiederfindet."""
|
||||||
treffer = re.search(r"\{.*\}", text, re.S)
|
treffer = re.search(r"\{.*\}", text, re.S)
|
||||||
if not treffer:
|
if not treffer:
|
||||||
raise ValueError("Antwort enthält kein JSON")
|
raise ValueError("Antwort enthält kein JSON")
|
||||||
daten = json.loads(treffer.group(0))
|
roh = treffer.group(0)
|
||||||
|
try:
|
||||||
|
# strict=False lässt Zeilenumbrüche in Strings durch
|
||||||
|
daten = json.loads(roh, strict=False)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
daten = json.loads(_quotes_maskieren(roh), strict=False)
|
||||||
probleme = []
|
probleme = []
|
||||||
|
gesehen = set() # dieselbe Stelle nur einmal melden
|
||||||
for p in daten.get("probleme") or []:
|
for p in daten.get("probleme") or []:
|
||||||
if not isinstance(p, dict) or not p.get("original"):
|
if not isinstance(p, dict) or not p.get("original"):
|
||||||
continue
|
continue
|
||||||
p["original"] = _ohne_strich(p["original"])
|
p["original"] = _ohne_strich(p["original"])
|
||||||
p["vorschlag"] = _ohne_strich(p.get("vorschlag"))
|
p["vorschlag"] = _ohne_strich(p.get("vorschlag"))
|
||||||
p["funktion"] = (p.get("funktion") or "").strip()
|
if not _anwendbar(p, felder):
|
||||||
|
continue
|
||||||
|
schluessel = (p.get("feld"), p["original"])
|
||||||
|
if schluessel in gesehen:
|
||||||
|
continue
|
||||||
|
gesehen.add(schluessel)
|
||||||
probleme.append(p)
|
probleme.append(p)
|
||||||
return probleme
|
return probleme
|
||||||
|
|
||||||
|
|
||||||
async def _pruefe_karte(stil: str, feature: dict) -> list[dict]:
|
def _anwendbar(p: dict, felder: tuple) -> bool:
|
||||||
text = await agents.frage(SYSTEM.format(stil=stil), feature["roh"])
|
"""Ein Problem ohne umsetzbaren Vorschlag ist für den Nutzer wertlos —
|
||||||
probleme = _antwort_lesen(text)
|
lieber verschweigen als einen leeren Hinweis anzeigen."""
|
||||||
|
feld = p.get("feld")
|
||||||
|
if feld not in felder:
|
||||||
|
return False # erfundenes Feld wie „struktur" — die Karte kennt es nicht
|
||||||
|
if not p["vorschlag"]:
|
||||||
|
return feld in LEERBAR # leerer Vorschlag streicht die Stelle
|
||||||
|
if "\n" in p["vorschlag"] or "\n" in p["original"]:
|
||||||
|
return feld in ZEILENFELDER # nur Listen haben Zeilen
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
||||||
|
"""Erst messen, dann fragen. Was der Code selbst reparieren kann, geht ohne
|
||||||
|
Anfrage durch; der Rest hängt als Auftrag am Ausschnitt."""
|
||||||
|
bereich = BEREICHE[aufgabe["bereich"]]
|
||||||
|
befunde = struktur.befunde(aufgabe["bereich"], aufgabe["felder"])
|
||||||
|
fertig = [b for b in befunde if b["vorschlag"]]
|
||||||
|
offen = [b for b in befunde if not b["vorschlag"]]
|
||||||
|
|
||||||
|
system = SYSTEM.format(
|
||||||
|
stil=stil[aufgabe["bereich"]],
|
||||||
|
gegenstand=bereich["gegenstand"],
|
||||||
|
felder=_felder_text(bereich["felder"]),
|
||||||
|
hinweis=bereich["hinweis"],
|
||||||
|
)
|
||||||
|
antwort = await agents.frage(system, inhalt(aufgabe, offen))
|
||||||
|
|
||||||
|
probleme = list(fertig)
|
||||||
|
belegt = {(b["feld"], b["original"]) for b in fertig}
|
||||||
|
for p in _antwort_lesen(antwort, bereich["felder"]):
|
||||||
|
if (p["feld"], p["original"]) in belegt:
|
||||||
|
continue # der gemessene Befund geht vor, er ist exakt
|
||||||
|
probleme.append(p)
|
||||||
|
# Herkunft stempelt der Code, nicht das Modell — es kennt nur seinen Ausschnitt
|
||||||
for p in probleme:
|
for p in probleme:
|
||||||
p["feature"] = feature["id"]
|
p["feature"] = aufgabe["feature"]
|
||||||
|
p["funktion"] = aufgabe["funktion"]
|
||||||
return probleme
|
return probleme
|
||||||
|
|
||||||
|
|
||||||
async def _lauf(projekt: str, liste: list[dict]):
|
async def _lauf(projekt: str, liste: list[dict], stil: dict):
|
||||||
lauf = _laeufe[projekt]
|
lauf = _laeufe[projekt]
|
||||||
stil = config.STIL_DATEI.read_text()
|
|
||||||
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
|
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
|
||||||
|
|
||||||
async def eine(feature):
|
async def eine(aufgabe):
|
||||||
async with schranke:
|
async with schranke:
|
||||||
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_karte(stil, feature))
|
lauf["probleme"].extend(await _pruefe(stil, aufgabe))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
lauf["meldung"] = str(e)
|
# Zählt trotzdem als erledigt, sonst hängt der Fortschritt.
|
||||||
|
# Die Meldung nennt die Zahl, damit „100 %" nicht vollständig vortäuscht.
|
||||||
|
lauf["fehler"] += 1
|
||||||
|
lauf["meldung"] = (
|
||||||
|
f"{lauf['fehler']} von {lauf['gesamt']} Prüfungen fehlgeschlagen: {e}"
|
||||||
|
)
|
||||||
lauf["fertig"] += 1
|
lauf["fertig"] += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*(eine(f) for f in liste))
|
await asyncio.gather(*(eine(a) for a in liste))
|
||||||
finally:
|
finally:
|
||||||
lauf["laeuft"] = False
|
lauf["laeuft"] = False
|
||||||
|
|
||||||
@@ -100,15 +310,19 @@ async def starte(projekt: str) -> dict:
|
|||||||
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
|
||||||
liste = features.liste(projekt)
|
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)]
|
||||||
_laeufe[projekt] = {
|
_laeufe[projekt] = {
|
||||||
"laeuft": True,
|
"laeuft": True,
|
||||||
"fertig": 0,
|
"fertig": 0,
|
||||||
"gesamt": len(liste),
|
"gesamt": len(liste),
|
||||||
|
"fehler": 0,
|
||||||
"probleme": [],
|
"probleme": [],
|
||||||
"meldung": "",
|
"meldung": "",
|
||||||
}
|
}
|
||||||
asyncio.create_task(_lauf(projekt, liste))
|
aufgabe = asyncio.create_task(_lauf(projekt, liste, stil))
|
||||||
|
_aufgaben.add(aufgabe)
|
||||||
|
aufgabe.add_done_callback(_aufgaben.discard)
|
||||||
return _laeufe[projekt]
|
return _laeufe[projekt]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ hier wird auf 400/404/409 gemappt."""
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
import agents
|
||||||
import features
|
import features
|
||||||
import projekte
|
import projekte
|
||||||
import pruefung
|
import pruefung
|
||||||
@@ -87,6 +88,8 @@ async def pruefung_starten(name: str):
|
|||||||
return await pruefung.starte(name)
|
return await pruefung.starte(name)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(404, str(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 ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(409, str(e))
|
raise HTTPException(409, str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
# Stil-Katalog für Karten
|
|
||||||
|
|
||||||
Verbindliche Vorgaben für alle Inhalte einer Feature-Karte. Der Prüfer meldet jede
|
|
||||||
Abweichung als Problem und schlägt einen korrigierten Text vor.
|
|
||||||
|
|
||||||
## Allgemein
|
|
||||||
|
|
||||||
- Sprache: Deutsch, Präsens, keine Anrede
|
|
||||||
- Kein Satzzeichen am Zeilenende (kein Punkt, kein Semikolon)
|
|
||||||
- Keine Füllwörter (auch, noch, einfach, jeweils, ggf.)
|
|
||||||
- Keine Klammern und keine Schrägstriche außer in Icon-Angaben
|
|
||||||
- Icons heißen `<Name>-Icon`, etwa `x-Icon` oder `Stift-Icon`; ein Doppelpunkt mit dem
|
|
||||||
tatsächlichen Zeichen dahinter ist erlaubt (`Nacht-Icon: ☾`)
|
|
||||||
- Maustasten als `LMB`, `RMB`, `MMB`
|
|
||||||
|
|
||||||
## Titel der Karte
|
|
||||||
|
|
||||||
- Höchstens 3 Wörter
|
|
||||||
- Substantiv, kein Verb
|
|
||||||
- Benennt die Sache, nicht die Tätigkeit
|
|
||||||
|
|
||||||
Richtig: `Features` · `Karten prüfen` · `Darkmode`
|
|
||||||
Falsch: `Features verwalten und anzeigen` (zu lang, Tätigkeit)
|
|
||||||
|
|
||||||
## Titel einer Funktion
|
|
||||||
|
|
||||||
- Höchstens 5 Wörter
|
|
||||||
- Kurzsatz aus Objekt und Verb im Infinitiv
|
|
||||||
- Genau eine Tätigkeit
|
|
||||||
|
|
||||||
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
|
|
||||||
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)
|
|
||||||
|
|
||||||
## Ziel
|
|
||||||
|
|
||||||
- Höchstens 2 Sätze, je höchstens 12 Wörter
|
|
||||||
- Beschreibt den Zweck, nicht die Bedienung
|
|
||||||
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
|
|
||||||
|
|
||||||
Richtig: `Gruppiert Informationen zu ähnlichen Funktionen`
|
|
||||||
Falsch: `Klick auf die Karte öffnet den Vollmodus` (Bedienung gehört zu Testen)
|
|
||||||
|
|
||||||
## Regeln
|
|
||||||
|
|
||||||
- Eine Zeile je Regel, höchstens 10 Wörter
|
|
||||||
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
|
|
||||||
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
|
|
||||||
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
|
|
||||||
|
|
||||||
Richtig: `Alphabetisch sortiert` · `Kern blau umrandet` · `x-Icon`
|
|
||||||
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
|
|
||||||
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
|
|
||||||
|
|
||||||
## Testen
|
|
||||||
|
|
||||||
- Eine Zeile je Schritt, höchstens 8 Wörter
|
|
||||||
- Schritte in Reihenfolge der Bedienung
|
|
||||||
- Letzte Zeile beschreibt den Endzustand, keine Handlung
|
|
||||||
- Handlungen als Nominalstil mit Maustaste oder Verb im Infinitiv
|
|
||||||
|
|
||||||
Richtig:
|
|
||||||
```
|
|
||||||
- Vollmodus öffnen
|
|
||||||
- RMB auf Funktion
|
|
||||||
- Klick auf "Funktion entfernen"
|
|
||||||
- Funktion entfernt
|
|
||||||
```
|
|
||||||
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)
|
|
||||||
17
backend/stil/allgemein.md
Normal file
17
backend/stil/allgemein.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Stil-Katalog für Karten
|
||||||
|
|
||||||
|
Verbindliche Vorgaben für alle Inhalte einer Feature-Karte. Der Prüfer meldet jede
|
||||||
|
Abweichung als Problem und schlägt einen korrigierten Text vor.
|
||||||
|
|
||||||
|
## Allgemein
|
||||||
|
|
||||||
|
Gilt für jedes Feld.
|
||||||
|
|
||||||
|
- Sprache: Deutsch, Präsens, keine Anrede
|
||||||
|
- Kein Satzzeichen am Zeilenende (kein Punkt, kein Semikolon)
|
||||||
|
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
|
||||||
|
- Keine Klammern und keine Schrägstriche außer in Icon-Angaben
|
||||||
|
- Icons heißen `<Name>-Icon`. Der Name darf ein Zeichen sein: `x-Icon`, `+-Icon`,
|
||||||
|
`Stift-Icon`. Ein Doppelpunkt mit dem tatsächlichen Zeichen dahinter ist erlaubt
|
||||||
|
(`Nacht-Icon: ☾`)
|
||||||
|
- Maustasten als `LMB`, `RMB`, `MMB`
|
||||||
11
backend/stil/feature-badge.md
Normal file
11
backend/stil/feature-badge.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## Badge der Karte
|
||||||
|
|
||||||
|
Feld `badge`. Genau zwei Werte erlaubt: `kern` oder `optional`.
|
||||||
|
|
||||||
|
- `kern`: ohne die Karte ist das Projekt nicht benutzbar
|
||||||
|
- `optional`: nützlich, aber verzichtbar
|
||||||
|
- Die Einstufung muss zum Ziel der Karte passen
|
||||||
|
- Im Zweifel `kern`
|
||||||
|
|
||||||
|
Richtig: `Projekte` ist kern · `Darkmode` ist optional
|
||||||
|
Falsch: `Darkmode` als kern (das Projekt läuft ohne)
|
||||||
13
backend/stil/feature-regeln.md
Normal file
13
backend/stil/feature-regeln.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
## Regeln der Karte
|
||||||
|
|
||||||
|
Feld `regeln`, wenn `funktion` leer ist. Gilt für die ganze Karte.
|
||||||
|
|
||||||
|
- Eine Zeile je Regel, höchstens 10 Wörter
|
||||||
|
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
|
||||||
|
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
|
||||||
|
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
|
||||||
|
- Ein Icon-Name allein ist eine gültige Regel
|
||||||
|
|
||||||
|
Richtig: `Alphabetisch sortiert` · `Verlassen speichert` · `Escape verwirft`
|
||||||
|
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
|
||||||
|
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
|
||||||
10
backend/stil/feature-titel.md
Normal file
10
backend/stil/feature-titel.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Titel der Karte
|
||||||
|
|
||||||
|
Feld `titel`, wenn `funktion` leer ist.
|
||||||
|
|
||||||
|
- Höchstens 3 Wörter
|
||||||
|
- Substantiv, oder Objekt mit Verb im Infinitiv
|
||||||
|
- Benennt die Sache, kein ganzer Satz
|
||||||
|
|
||||||
|
Richtig: `Features` · `Karten prüfen` · `Darkmode`
|
||||||
|
Falsch: `Features verwalten und anzeigen` (zu lang, Tätigkeit)
|
||||||
10
backend/stil/feature-ziel.md
Normal file
10
backend/stil/feature-ziel.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Ziel der Karte
|
||||||
|
|
||||||
|
Feld `ziel`, wenn `funktion` leer ist.
|
||||||
|
|
||||||
|
- Höchstens 2 Sätze, je höchstens 12 Wörter
|
||||||
|
- Beschreibt den Zweck der Karte, nicht die Bedienung
|
||||||
|
- Entfällt, wenn der Titel selbsterklärend ist — dann bleibt das Feld leer
|
||||||
|
|
||||||
|
Richtig: `Gruppiert Informationen zu ähnlichen Funktionen`
|
||||||
|
Falsch: `Klick auf die Karte öffnet den Vollmodus` (Bedienung gehört zu Testen)
|
||||||
13
backend/stil/funktion-regeln.md
Normal file
13
backend/stil/funktion-regeln.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
## Regeln einer Funktion
|
||||||
|
|
||||||
|
Feld `regeln`, wenn `funktion` gesetzt ist. Gilt nur für diese Funktion.
|
||||||
|
|
||||||
|
- Eine Zeile je Regel, höchstens 10 Wörter
|
||||||
|
- Genau eine Aussage je Zeile; zwei Aussagen werden zu zwei Regeln
|
||||||
|
- Beschreibt eine Eigenschaft oder Bedingung, keinen Ablaufschritt
|
||||||
|
- Keine Aufzählung mit Komma oder „und" zweier gleichrangiger Aussagen
|
||||||
|
- Ein Icon-Name allein ist eine gültige Regel
|
||||||
|
|
||||||
|
Richtig: `x-Icon` · `Kern blau umrandet` · `Aktualisiert den Dateinamen`
|
||||||
|
Falsch: `Kern blau umrandet, Optional grün umrandet` (zwei Aussagen)
|
||||||
|
Falsch: `Klick auf Badge` (Ablaufschritt, gehört zu Testen)
|
||||||
18
backend/stil/funktion-testen.md
Normal file
18
backend/stil/funktion-testen.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
## Testen einer Funktion
|
||||||
|
|
||||||
|
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
|
||||||
|
|
||||||
|
- Eine Zeile je Schritt, höchstens 8 Wörter
|
||||||
|
- Schritte in Reihenfolge der Bedienung
|
||||||
|
- Kein Schritt der Bedienung fehlt
|
||||||
|
- Letzte Zeile beschreibt den Endzustand, keine Handlung
|
||||||
|
- Handlungen als Nominalstil mit Maustaste oder Verb im Infinitiv
|
||||||
|
|
||||||
|
Richtig:
|
||||||
|
```
|
||||||
|
- Vollmodus öffnen
|
||||||
|
- RMB auf Funktion
|
||||||
|
- Klick auf "Funktion entfernen"
|
||||||
|
- Funktion entfernt
|
||||||
|
```
|
||||||
|
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)
|
||||||
10
backend/stil/funktion-titel.md
Normal file
10
backend/stil/funktion-titel.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Titel einer Funktion
|
||||||
|
|
||||||
|
Feld `titel`, wenn `funktion` gesetzt ist.
|
||||||
|
|
||||||
|
- Höchstens 5 Wörter
|
||||||
|
- Kurzsatz aus Objekt und Verb im Infinitiv
|
||||||
|
- Genau eine Tätigkeit
|
||||||
|
|
||||||
|
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
|
||||||
|
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)
|
||||||
10
backend/stil/funktion-ziel.md
Normal file
10
backend/stil/funktion-ziel.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## Ziel einer Funktion
|
||||||
|
|
||||||
|
Feld `ziel`, wenn `funktion` gesetzt ist.
|
||||||
|
|
||||||
|
- Höchstens 2 Sätze, je höchstens 12 Wörter
|
||||||
|
- Beschreibt den Zweck der Funktion, nicht die Bedienung
|
||||||
|
- Entfällt, wenn der Funktionstitel selbsterklärend ist — dann bleibt das Feld leer
|
||||||
|
|
||||||
|
Richtig: `Beschreibt kurz das Kernziel der Funktion`
|
||||||
|
Falsch: `Klick auf Regeln der Funktion` (Bedienung gehört zu Testen)
|
||||||
107
backend/struktur.py
Normal file
107
backend/struktur.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Stufe 1 der Prüfung: alles, was sich zählen lässt, zählt der Code.
|
||||||
|
|
||||||
|
Das Modell erfand hier Regeln, die im Katalog nicht stehen — „Im" als Füllwort,
|
||||||
|
Satzzeichen, wo keins ist. Diese Klasse ist jetzt abschließend hier erledigt, und
|
||||||
|
der Prompt verbietet dem Modell, sie noch einmal zu melden.
|
||||||
|
|
||||||
|
Ein Befund mit Vorschlag geht ohne Anfrage durch. Ein Befund ohne Vorschlag heißt:
|
||||||
|
der Code weiß, dass die Stelle falsch ist, aber nicht, was richtig wäre — den
|
||||||
|
Auftrag bekommt das Modell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Höchstzahl Wörter je Zeile, nach (bereich, feld)
|
||||||
|
WORTGRENZEN = {
|
||||||
|
("feature", "titel"): 3,
|
||||||
|
("funktion", "titel"): 5,
|
||||||
|
("feature", "regeln"): 10,
|
||||||
|
("funktion", "regeln"): 10,
|
||||||
|
("funktion", "testen"): 8,
|
||||||
|
}
|
||||||
|
ZIEL_SAETZE = 2
|
||||||
|
ZIEL_WOERTER = 12
|
||||||
|
|
||||||
|
# Abschließend. Genau diese Wörter, keine sinnverwandten.
|
||||||
|
FUELLWOERTER = ("auch", "noch", "einfach", "jeweils", "ggf")
|
||||||
|
BADGE = ("kern", "optional")
|
||||||
|
ZEILENFELDER = ("regeln", "testen")
|
||||||
|
|
||||||
|
SATZENDE = ".;"
|
||||||
|
_KLAMMER = re.compile(r"[()/]")
|
||||||
|
_STRICH = re.compile(r"^[-*]\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def _zeilen(feld: str, text: str) -> list[str]:
|
||||||
|
"""regeln und testen sind Listen, alles andere ist ein Block."""
|
||||||
|
if feld in ZEILENFELDER:
|
||||||
|
return [_STRICH.sub("", z.strip()) for z in text.splitlines() if z.strip()]
|
||||||
|
return [text.strip()] if text.strip() else []
|
||||||
|
|
||||||
|
|
||||||
|
def _problem(feld: str, original: str, problem: str, vorschlag: str = "") -> dict:
|
||||||
|
return {"feld": feld, "original": original, "problem": problem, "vorschlag": vorschlag}
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_wort(zeile: str, wort: str) -> str:
|
||||||
|
neu = re.sub(rf"\s*\b{re.escape(wort)}\b\.?", "", zeile, flags=re.I).strip()
|
||||||
|
return re.sub(r"\s{2,}", " ", neu)
|
||||||
|
|
||||||
|
|
||||||
|
def _reparierbar(zeile: str) -> tuple[list[str], str]:
|
||||||
|
"""Verstöße, die sich wegstreichen lassen, samt der bereinigten Zeile."""
|
||||||
|
gruende, neu = [], zeile
|
||||||
|
if neu[-1] in SATZENDE:
|
||||||
|
gruende.append("Satzzeichen am Zeilenende")
|
||||||
|
neu = neu[:-1].rstrip()
|
||||||
|
for wort in FUELLWOERTER:
|
||||||
|
if re.search(rf"\b{wort}\b", neu, re.I):
|
||||||
|
gruende.append(f"Füllwort „{wort}“")
|
||||||
|
neu = _ohne_wort(neu, wort)
|
||||||
|
return gruende, neu
|
||||||
|
|
||||||
|
|
||||||
|
def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
|
||||||
|
"""Verstöße, die der Code sieht, aber nicht beheben kann."""
|
||||||
|
gruende = []
|
||||||
|
if _KLAMMER.search(zeile):
|
||||||
|
gruende.append("Klammer oder Schrägstrich")
|
||||||
|
grenze = WORTGRENZEN.get((bereich, feld))
|
||||||
|
if grenze and len(zeile.split()) > grenze:
|
||||||
|
gruende.append(f"Mehr als {grenze} Wörter")
|
||||||
|
if feld == "ziel":
|
||||||
|
saetze = [s.strip() for s in re.split(r"[.!?]+", zeile) if s.strip()]
|
||||||
|
if len(saetze) > ZIEL_SAETZE:
|
||||||
|
gruende.append(f"Mehr als {ZIEL_SAETZE} Sätze")
|
||||||
|
if any(len(s.split()) > ZIEL_WOERTER for s in saetze):
|
||||||
|
gruende.append(f"Satz mit mehr als {ZIEL_WOERTER} Wörtern")
|
||||||
|
return gruende
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
|
||||||
|
"""Höchstens ein Befund je Zeile. Zwei Vorschläge für dieselbe Zeile wären nicht
|
||||||
|
unabhängig — der zweite fände seine Stelle nach dem ersten nicht mehr."""
|
||||||
|
gruende, neu = _reparierbar(zeile)
|
||||||
|
offen = _umschreiben(bereich, feld, zeile)
|
||||||
|
if offen:
|
||||||
|
# Muss ohnehin neu geschrieben werden — dann alles in einem Zug, vom Modell
|
||||||
|
return [_problem(feld, zeile, ", ".join(gruende + offen))]
|
||||||
|
return [_problem(feld, zeile, ", ".join(gruende), neu)] if gruende else []
|
||||||
|
|
||||||
|
|
||||||
|
def befunde(bereich: str, felder: dict) -> list[dict]:
|
||||||
|
"""Alle messbaren Verstöße einer Prüfstelle, in Reihenfolge der Felder."""
|
||||||
|
raus = []
|
||||||
|
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 ""):
|
||||||
|
raus += _zeile(bereich, feld, zeile)
|
||||||
|
return raus
|
||||||
|
|
||||||
|
|
||||||
|
def auftrag(befund: dict) -> str:
|
||||||
|
"""Zeile für das Modell: der Code hat gemessen, den Ersatz schreibt das Modell."""
|
||||||
|
return f'- {befund["feld"]} „{befund["original"]}“: {befund["problem"]}'
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ import projekte
|
|||||||
import pruefung
|
import pruefung
|
||||||
|
|
||||||
ANTWORT = """```json
|
ANTWORT = """```json
|
||||||
{"probleme": [{"feld": "regeln", "funktion": "Etwas tun", "original": "Zwei Aussagen, und noch eine",
|
{"probleme": [{"feld": "regeln", "original": "Zwei Aussagen, und noch eine",
|
||||||
"problem": "Zwei Aussagen je Regel", "vorschlag": "Zwei Aussagen"}]}
|
"problem": "Zwei Aussagen je Regel", "vorschlag": "Zwei Aussagen"}]}
|
||||||
```"""
|
```"""
|
||||||
|
|
||||||
@@ -71,7 +72,11 @@ async def test_providerfehler_landet_in_meldung(projekt, monkeypatch):
|
|||||||
monkeypatch.setattr(agents, "frage", kaputt)
|
monkeypatch.setattr(agents, "frage", kaputt)
|
||||||
await pruefung.starte(projekt)
|
await pruefung.starte(projekt)
|
||||||
await warte_bis_fertig(projekt)
|
await warte_bis_fertig(projekt)
|
||||||
assert pruefung.status(projekt)["meldung"] == "Provider antwortet mit HTTP 500"
|
s = pruefung.status(projekt)
|
||||||
|
assert s["fehler"] == 2
|
||||||
|
assert s["meldung"] == (
|
||||||
|
"2 von 2 Prüfungen fehlgeschlagen: Provider antwortet mit HTTP 500"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_verworfener_lauf_stoppt(projekt, monkeypatch):
|
async def test_verworfener_lauf_stoppt(projekt, monkeypatch):
|
||||||
@@ -114,23 +119,214 @@ 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, "probleme": [], "meldung": ""
|
"laeuft": False, "fertig": 0, "gesamt": 0, "fehler": 0,
|
||||||
|
"probleme": [], "meldung": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_antwort_lesen_ohne_zaun():
|
def test_antwort_lesen_ohne_zaun():
|
||||||
probleme = pruefung._antwort_lesen('{"probleme": [{"original": "x", "vorschlag": "y"}]}')
|
probleme = pruefung._antwort_lesen('{"probleme": [{"feld": "titel", "original": "x", "vorschlag": "y"}]}')
|
||||||
assert probleme == [{"original": "x", "vorschlag": "y", "funktion": ""}]
|
assert probleme == [{"feld": "titel", "original": "x", "vorschlag": "y"}]
|
||||||
|
|
||||||
|
|
||||||
def test_listenstrich_wird_entfernt():
|
def test_listenstrich_wird_entfernt():
|
||||||
probleme = pruefung._antwort_lesen(
|
probleme = pruefung._antwort_lesen(
|
||||||
'{"probleme": [{"original": "- alt", "vorschlag": "- neu", "funktion": " F "}]}')
|
'{"probleme": [{"feld": "regeln", "original": "- alt", "vorschlag": "- neu"}]}')
|
||||||
assert probleme[0]["original"] == "alt"
|
assert probleme[0]["original"] == "alt"
|
||||||
assert probleme[0]["vorschlag"] == "neu"
|
assert probleme[0]["vorschlag"] == "neu"
|
||||||
assert probleme[0]["funktion"] == "F"
|
|
||||||
|
|
||||||
|
|
||||||
def test_antwort_ohne_json():
|
def test_antwort_ohne_json():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
pruefung._antwort_lesen("keine Ahnung")
|
pruefung._antwort_lesen("keine Ahnung")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmaskierte_quotes_im_original():
|
||||||
|
# Karteninhalt enthält Anführungszeichen; das Modell maskiert sie nicht
|
||||||
|
probleme = pruefung._antwort_lesen(
|
||||||
|
'{"probleme": [{"feld": "testen", "original": "Klick auf "Feature entfernen"",'
|
||||||
|
' "problem": "x", "vorschlag": "Klick auf "Feature entfernen" mit LMB"}]}')
|
||||||
|
assert probleme[0]["original"] == 'Klick auf "Feature entfernen"'
|
||||||
|
assert probleme[0]["vorschlag"] == 'Klick auf "Feature entfernen" mit LMB'
|
||||||
|
|
||||||
|
|
||||||
|
def test_gueltiges_json_bleibt_unberuehrt():
|
||||||
|
probleme = pruefung._antwort_lesen(
|
||||||
|
r'{"probleme": [{"feld": "testen", "original": "Klick auf \"X\"", "vorschlag": "y"}]}')
|
||||||
|
assert probleme[0]["original"] == 'Klick auf "X"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeilenumbruch_im_string():
|
||||||
|
probleme = pruefung._antwort_lesen(
|
||||||
|
'{"probleme": [{"feld": "testen", "original": "erste\nzweite", "vorschlag": "y"}]}')
|
||||||
|
assert probleme[0]["original"] == "erste\nzweite"
|
||||||
|
|
||||||
|
|
||||||
|
def _eins(felder=pruefung.ALLE_FELDER, **werte):
|
||||||
|
p = {"feld": "testen", "original": "Bestätigen", "problem": "x", "vorschlag": "y"}
|
||||||
|
p.update(werte)
|
||||||
|
return pruefung._antwort_lesen(json.dumps({"probleme": [p]}), felder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mehrzeiliger_vorschlag_bleibt_erhalten():
|
||||||
|
# Die alte Zeile bleibt stehen, der Endzustand kommt dahinter — ohne Aktionsfeld
|
||||||
|
p = _eins(vorschlag="- Bestätigen mit LMB\n- Feature erstellt")[0]
|
||||||
|
assert p["vorschlag"] == "Bestätigen mit LMB\nFeature erstellt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leerer_vorschlag_nur_wo_das_feld_leer_sein_darf():
|
||||||
|
for feld in ("regeln", "testen", "ziel"):
|
||||||
|
assert len(_eins(feld=feld, vorschlag="")) == 1 # streicht die Stelle
|
||||||
|
for feld in ("titel", "badge"):
|
||||||
|
assert _eins(feld=feld, vorschlag="") == [] # wären danach kaputt
|
||||||
|
|
||||||
|
|
||||||
|
def test_mehrere_zeilen_nur_bei_listenfeldern():
|
||||||
|
for feld in ("regeln", "testen"):
|
||||||
|
assert len(_eins(feld=feld, vorschlag="a\nb")) == 1
|
||||||
|
for feld in ("titel", "badge", "ziel"):
|
||||||
|
assert _eins(feld=feld, vorschlag="a\nb") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_erfundenes_feld_wird_verworfen():
|
||||||
|
# Das Modell meldete „struktur" — kein Feld der Karte, also nie anwendbar
|
||||||
|
assert _eins(feld="struktur") == []
|
||||||
|
assert _eins(feld="") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_feld_muss_zum_bereich_passen():
|
||||||
|
feature = pruefung.BEREICHE["feature"]["felder"]
|
||||||
|
funktion = pruefung.BEREICHE["funktion"]["felder"]
|
||||||
|
assert _eins(feld="testen", felder=funktion) != []
|
||||||
|
assert _eins(feld="testen", felder=feature) == [] # Karte hat kein testen
|
||||||
|
assert _eins(feld="badge", vorschlag="kern", felder=feature) != []
|
||||||
|
assert _eins(feld="badge", vorschlag="kern", felder=funktion) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_stelle_nur_einmal():
|
||||||
|
probleme = pruefung._antwort_lesen(
|
||||||
|
'{"probleme": ['
|
||||||
|
'{"feld": "regeln", "original": "a", "problem": "eins", "vorschlag": "x"},'
|
||||||
|
'{"feld": "regeln", "original": "a", "problem": "zwei", "vorschlag": "y"},'
|
||||||
|
'{"feld": "testen", "original": "a", "problem": "drei", "vorschlag": "z"}]}')
|
||||||
|
assert [p["problem"] for p in probleme] == ["eins", "drei"] # feld trennt
|
||||||
|
|
||||||
|
|
||||||
|
KARTE = (
|
||||||
|
"<!-- titel -->\nLogin\n<!-- art -->\noptional\n<!-- ziel -->\nAnmelden\n"
|
||||||
|
"<!-- regeln -->\n- Kartenregel\n"
|
||||||
|
"<!-- funktion -->\nAnmelden\n<!-- testen -->\n- Formular öffnen\n"
|
||||||
|
"<!-- funktion -->\nAbmelden\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufgaben_eine_je_ebene(datenordner):
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
f = features.anlegen("Test", KARTE)
|
||||||
|
liste = pruefung.aufgaben(f)
|
||||||
|
|
||||||
|
# Dateireihenfolge, nicht alphabetisch — sortiert wird erst in der Anzeige
|
||||||
|
assert [a["funktion"] for a in liste] == ["", "Anmelden", "Abmelden"]
|
||||||
|
assert [a["bereich"] for a in liste] == ["feature", "funktion", "funktion"]
|
||||||
|
assert all(a["feature"] == "login" for a in liste)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufgabe_bekommt_nur_ihren_ausschnitt(datenordner):
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
f = features.anlegen("Test", KARTE)
|
||||||
|
karte, anmelden, abmelden = pruefung.aufgaben(f)
|
||||||
|
inhalt = {a["funktion"]: pruefung.inhalt(a) for a in (karte, anmelden, abmelden)}
|
||||||
|
|
||||||
|
# Kartenebene: badge statt art, Funktionstitel nur als Kontext für den Badge
|
||||||
|
assert "<!-- badge -->\noptional" in inhalt[""]
|
||||||
|
assert "<!-- regeln -->\n- Kartenregel" in inhalt[""]
|
||||||
|
assert "<!-- funktionen -->\n- Anmelden\n- Abmelden" in inhalt[""]
|
||||||
|
assert "Formular öffnen" not in inhalt[""] # keine Details der Funktionen
|
||||||
|
|
||||||
|
# Funktion: eigener Titel, eigenes testen, nichts von der Karte
|
||||||
|
assert "<!-- titel -->\nAnmelden" in inhalt["Anmelden"]
|
||||||
|
assert "<!-- testen -->\n- Formular öffnen" in inhalt["Anmelden"]
|
||||||
|
assert "Kartenregel" not in inhalt["Anmelden"]
|
||||||
|
|
||||||
|
# Leere Felder werden gar nicht erst mitgeschickt
|
||||||
|
assert inhalt["Abmelden"] == "<!-- titel -->\nAbmelden\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lauf_zaehlt_alle_stellen(datenordner, monkeypatch):
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
features.anlegen("Test", KARTE)
|
||||||
|
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
|
||||||
|
gesehen = []
|
||||||
|
|
||||||
|
async def merke(system, inhalt):
|
||||||
|
gesehen.append((system, inhalt))
|
||||||
|
return '{"probleme": []}'
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents, "frage", merke)
|
||||||
|
await pruefung.starte("Test")
|
||||||
|
await warte_bis_fertig("Test")
|
||||||
|
|
||||||
|
assert pruefung.status("Test")["gesamt"] == 3 # 1 Karte + 2 Funktionen
|
||||||
|
assert len(gesehen) == 3
|
||||||
|
# Der Kartenprüfer kennt die Badge-Regeln, der Funktionsprüfer nicht
|
||||||
|
karten_system = next(s for s, i in gesehen if "badge" in i)
|
||||||
|
assert "## Badge der Karte" in karten_system
|
||||||
|
assert "## Testen einer Funktion" not in karten_system
|
||||||
|
|
||||||
|
|
||||||
|
async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch):
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
features.anlegen("Test", KARTE)
|
||||||
|
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
|
||||||
|
|
||||||
|
async def luegt(system, inhalt):
|
||||||
|
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
|
||||||
|
return ('{"probleme": [{"feld": "titel", "funktion": "Erfunden",'
|
||||||
|
' "original": "x", "vorschlag": "y"}]}')
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents, "frage", luegt)
|
||||||
|
await pruefung.starte("Test")
|
||||||
|
await warte_bis_fertig("Test")
|
||||||
|
|
||||||
|
zuordnung = sorted(p["funktion"] for p in pruefung.status("Test")["probleme"])
|
||||||
|
assert zuordnung == ["", "Abmelden", "Anmelden"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stil_katalog_je_bereich():
|
||||||
|
feature = pruefung._stil_lesen("feature")
|
||||||
|
funktion = pruefung._stil_lesen("funktion")
|
||||||
|
|
||||||
|
# allgemein steckt in beiden und steht vorn
|
||||||
|
for stil in (feature, funktion):
|
||||||
|
assert stil.startswith("# Stil-Katalog")
|
||||||
|
assert "## Allgemein" in stil
|
||||||
|
|
||||||
|
for ueberschrift in (
|
||||||
|
"## Titel der Karte", "## Badge der Karte",
|
||||||
|
"## Ziel der Karte", "## Regeln der Karte",
|
||||||
|
):
|
||||||
|
assert ueberschrift in feature
|
||||||
|
assert ueberschrift not in funktion # der Funktionsprüfer sieht das nicht
|
||||||
|
|
||||||
|
for ueberschrift in (
|
||||||
|
"## Titel einer Funktion", "## Ziel einer Funktion",
|
||||||
|
"## Regeln einer Funktion", "## Testen einer Funktion",
|
||||||
|
):
|
||||||
|
assert ueberschrift in funktion
|
||||||
|
assert ueberschrift not in feature
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_stil_datei_kommt_ohne_code_dazu(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "allgemein.md").write_text("## Allgemein\n")
|
||||||
|
(tmp_path / "feature-neu.md").write_text("## Neu\n")
|
||||||
|
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
|
||||||
|
stil = pruefung._stil_lesen("feature")
|
||||||
|
assert "## Neu" in stil
|
||||||
|
assert stil.index("## Allgemein") < stil.index("## Neu") # alphabetisch
|
||||||
|
assert "## Neu" not in pruefung._stil_lesen("funktion")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stil_ordner_leer_bricht_ab(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pruefung._stil_lesen("feature")
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ async def test_feature_crud(client):
|
|||||||
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 404
|
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pruefung_ohne_konfiguration(client, monkeypatch):
|
||||||
|
import agents
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents.config, "MINIMAX_API_KEY", "")
|
||||||
|
await client.post("/api/projekte", json={"name": "Alpha"})
|
||||||
|
res = await client.post("/api/projekte/Alpha/pruefung")
|
||||||
|
# 400, nicht 409 — 409 heißt „läuft bereits" und würde die Meldung verschlucken
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert "MINIMAX_API_KEY" in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
async def test_unbekanntes_projekt(client):
|
async def test_unbekanntes_projekt(client):
|
||||||
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
|
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
|
||||||
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
|
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
|
||||||
|
|||||||
146
backend/tests/test_struktur.py
Normal file
146
backend/tests/test_struktur.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import pruefung
|
||||||
|
import struktur
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe(bereich, feld, text):
|
||||||
|
return struktur.befunde(bereich, {feld: text})
|
||||||
|
|
||||||
|
|
||||||
|
def _probleme(befunde):
|
||||||
|
return [b["problem"] for b in befunde]
|
||||||
|
|
||||||
|
|
||||||
|
def test_satzzeichen_am_zeilenende():
|
||||||
|
b = _pruefe("funktion", "regeln", "- Passwort nie loggen.")[0]
|
||||||
|
assert b["problem"] == "Satzzeichen am Zeilenende"
|
||||||
|
assert b["vorschlag"] == "Passwort nie loggen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_punkt_im_wort_ist_kein_satzende():
|
||||||
|
# Der Prüfer meldete hier „Punkt am Zeilenende" — die Zeile endet auf „v"
|
||||||
|
assert _pruefe("funktion", "regeln", "- API Zugänge stehen in .env") == []
|
||||||
|
assert _pruefe("funktion", "regeln", "- Nur kern und optional erlaubt") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuellwort_wird_entfernt():
|
||||||
|
b = _pruefe("funktion", "regeln", "- Auch Features entfernen")[0]
|
||||||
|
assert b["problem"] == "Füllwort „auch“"
|
||||||
|
assert b["vorschlag"] == "Features entfernen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_der_fuellwoerter_ist_abschliessend():
|
||||||
|
# Das Modell hielt „Im", „Nur" und „direkt" für Füllwörter — sind sie nicht
|
||||||
|
for zeile in ("- Im Modal Namen eingeben", "- Nur wenige Wörter",
|
||||||
|
"- Feature direkt in Vollmodus geöffnet"):
|
||||||
|
assert _pruefe("funktion", "regeln", zeile) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_wortgrenze_je_bereich_und_feld():
|
||||||
|
assert _probleme(_pruefe("feature", "titel", "Karten der Features prüfen")) \
|
||||||
|
== ["Mehr als 3 Wörter"]
|
||||||
|
assert _pruefe("funktion", "titel", "Karten der Features prüfen") == []
|
||||||
|
assert _probleme(_pruefe("funktion", "testen", "- Eins zwei drei vier fünf sechs sieben acht neun")) \
|
||||||
|
== ["Mehr als 8 Wörter"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_vorschlag_wenn_der_code_nicht_kuerzen_kann():
|
||||||
|
b = _pruefe("funktion", "testen", "- Eins zwei drei vier fünf sechs sieben acht neun")[0]
|
||||||
|
assert b["vorschlag"] == "" # den Ersatz schreibt das Modell
|
||||||
|
|
||||||
|
|
||||||
|
def test_klammer_und_schraegstrich():
|
||||||
|
assert _probleme(_pruefe("funktion", "regeln", "- Rand blau (kern)")) \
|
||||||
|
== ["Klammer oder Schrägstrich"]
|
||||||
|
assert _probleme(_pruefe("funktion", "regeln", "- Kern/Optional getrennt")) \
|
||||||
|
== ["Klammer oder Schrägstrich"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ziel_saetze_und_laenge():
|
||||||
|
lang = "Dieses Ziel hat deutlich mehr als zwölf Wörter in einem einzigen langen Satz"
|
||||||
|
assert "Satz mit mehr als 12 Wörtern" in _probleme(_pruefe("feature", "ziel", lang))
|
||||||
|
assert _probleme(_pruefe("feature", "ziel", "Eins. Zwei. Drei")) == ["Mehr als 2 Sätze"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_nur_kern_oder_optional():
|
||||||
|
assert _pruefe("feature", "badge", "optional") == []
|
||||||
|
b = _pruefe("feature", "badge", "wichtig")[0]
|
||||||
|
assert b["vorschlag"] == "kern"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jede_zeile_einzeln():
|
||||||
|
befunde = _pruefe("funktion", "regeln", "- Erste Regel.\n- Auch zweite Regel")
|
||||||
|
assert _probleme(befunde) == ["Satzzeichen am Zeilenende", "Füllwort „auch“"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_befund_je_zeile():
|
||||||
|
# Zwei Vorschläge für dieselbe Zeile wären nicht unabhängig anwendbar
|
||||||
|
befunde = _pruefe("funktion", "regeln", "- Auch Passwort prüfen.")
|
||||||
|
assert len(befunde) == 1
|
||||||
|
assert befunde[0]["problem"] == "Satzzeichen am Zeilenende, Füllwort „auch“"
|
||||||
|
assert befunde[0]["vorschlag"] == "Passwort prüfen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreparierbares_zieht_den_ganzen_befund_zum_modell():
|
||||||
|
# Zu lang und Füllwort: der Code streicht nicht halb, das Modell schreibt neu
|
||||||
|
b = _pruefe("funktion", "testen", "- Auch eins zwei drei vier fünf sechs sieben acht")[0]
|
||||||
|
assert b["problem"] == "Füllwort „auch“, Mehr als 8 Wörter"
|
||||||
|
assert b["vorschlag"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_auftrag_nennt_feld_und_stelle():
|
||||||
|
b = _pruefe("feature", "titel", "Karten der Features prüfen")[0]
|
||||||
|
assert struktur.auftrag(b) == '- titel „Karten der Features prüfen“: Mehr als 3 Wörter'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pruefe_verbindet_messung_und_modell(datenordner, monkeypatch):
|
||||||
|
import agents
|
||||||
|
import features
|
||||||
|
import projekte
|
||||||
|
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
f = features.anlegen(
|
||||||
|
"Test",
|
||||||
|
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
|
||||||
|
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
|
||||||
|
)
|
||||||
|
gesehen = []
|
||||||
|
|
||||||
|
async def modell(system, inhalt):
|
||||||
|
gesehen.append(inhalt)
|
||||||
|
# Das Modell meldet ein erfundenes Feld und dieselbe Zeile noch einmal
|
||||||
|
return ('{"probleme": ['
|
||||||
|
'{"feld": "struktur", "original": "x", "vorschlag": "y"},'
|
||||||
|
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
|
||||||
|
' "problem": "Füllwort", "vorschlag": "Passwort prüfen"},'
|
||||||
|
'{"feld": "regeln", "original": "Auch Passwort prüfen.",'
|
||||||
|
' "problem": "zwei Aussagen", "vorschlag": "Passwort prüfen"}]}')
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents, "frage", modell)
|
||||||
|
aufgabe = pruefung.aufgaben(f)[1]
|
||||||
|
probleme = await pruefung._pruefe({"funktion": "<KATALOG>"}, aufgabe)
|
||||||
|
|
||||||
|
# Ein Befund für die Zeile, beide Verstöße auf einmal behoben — ohne Modell
|
||||||
|
assert [p["problem"] for p in probleme] == [
|
||||||
|
"Satzzeichen am Zeilenende, Füllwort „auch“",
|
||||||
|
]
|
||||||
|
assert probleme[0]["vorschlag"] == "Passwort prüfen"
|
||||||
|
# Erfundenes Feld fällt raus, Doppelmeldung der gemessenen Zeile ebenso
|
||||||
|
assert probleme[0]["feld"] == "regeln"
|
||||||
|
assert probleme[0]["funktion"] == "Anmelden"
|
||||||
|
# Nichts zu kürzen -> kein Auftrag im Ausschnitt
|
||||||
|
assert "Gemessen" not in gesehen[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemessene_befunde_haengen_am_ausschnitt(datenordner):
|
||||||
|
import features
|
||||||
|
import projekte
|
||||||
|
|
||||||
|
projekte.anlegen("Test")
|
||||||
|
f = features.anlegen("Test", "<!-- titel -->\nEin viel zu langer Kartentitel\n")
|
||||||
|
karte = pruefung.aufgaben(f)[0]
|
||||||
|
befunde = struktur.befunde("feature", karte["felder"])
|
||||||
|
|
||||||
|
assert "Gemessen" not in pruefung.inhalt(karte)
|
||||||
|
text = pruefung.inhalt(karte, befunde)
|
||||||
|
assert "Gemessen, bitte neu schreiben:" in text
|
||||||
|
assert "Mehr als 3 Wörter" in text
|
||||||
@@ -18,5 +18,9 @@ Darkmode umschalten
|
|||||||
- Auto-Icon: A
|
- Auto-Icon: A
|
||||||
- Zustand bleibt gespeichert
|
- Zustand bleibt gespeichert
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- LMB auf Icon in der Sidebar oben
|
- LMB auf Sidebar-Icon
|
||||||
- Modus je nach vorherigem Zustand Auto, Tag oder Nacht
|
- Erster Modus ist Auto
|
||||||
|
- LMB auf Icon
|
||||||
|
- Zweiter Modus ist Tag
|
||||||
|
- LMB auf Icon
|
||||||
|
- Dritter Modus ist Nacht
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ Gruppiert Informationen zu ähnlichen Funktionen
|
|||||||
----------------------------------------
|
----------------------------------------
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Alphabetisch sortiert
|
- Alphabetisch sortiert
|
||||||
- Unter daten: als md Datei gespeichert
|
- Unter daten als md gespeichert
|
||||||
- Verlassen speichert
|
- Verlassen speichert
|
||||||
- Escape verwirft
|
- Escape verwirft
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Anzeigemodus umschalten
|
Anzeigemodus umschalten
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Kompaktmodus zum Lesen und Vollmodus zum Vertiefen und Anpassen
|
Erlaubt schnelles Lesen oder detaillierte Bearbeitung
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Standard ist Kompaktmodus
|
- Standard ist Kompaktmodus
|
||||||
- Vollmodus geht über responsive Spalten
|
- Vollmodus geht über responsive Spalten
|
||||||
- Vollmodus zeigt alles
|
- Vollmodus zeigt alle Felder
|
||||||
- Kompaktmodus zeigt Ziel und Funktionstitel als Liste
|
- Kompaktmodus zeigt Ziel und Funktionstitel als Liste
|
||||||
- Augen-Icon rechts oben
|
- Augen-Icon rechts oben
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
@@ -35,6 +35,7 @@ Unterscheidet die Kern- und optionalen Features
|
|||||||
- Kernfarbe blau
|
- Kernfarbe blau
|
||||||
- Optionalfarbe grün
|
- Optionalfarbe grün
|
||||||
- Verändert die Randfarbe vom Feature
|
- Verändert die Randfarbe vom Feature
|
||||||
|
- Prüfung meldet falsche Einstufung
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Vollmodus öffnen
|
- Vollmodus öffnen
|
||||||
- Klick auf Badge mit LMB
|
- Klick auf Badge mit LMB
|
||||||
@@ -51,7 +52,7 @@ Feature entfernen
|
|||||||
Feature erstellen
|
Feature erstellen
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- +-Icon
|
- +-Icon
|
||||||
- Feature direkt in Vollmodus geöffnet
|
- Feature in Vollmodus geöffnet
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- RMB auf Hauptbereich
|
- RMB auf Hauptbereich
|
||||||
- Klick auf "Feature erstellen"
|
- Klick auf "Feature erstellen"
|
||||||
|
|||||||
@@ -15,12 +15,27 @@ Prüft alle Karten auf Stilvorgaben und schlägt Verbesserungen vor
|
|||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Änderungsvorschläge anzeigen
|
Änderungsvorschläge anzeigen
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Annehmen und Ablehnen Buttons oben rechts
|
- Annehmen als Haken-Icon: ✓
|
||||||
- Buttons laden nur die Problemstelle neu
|
- Ablehnen als x-Icon: ✕
|
||||||
|
- Icons stehen oben rechts
|
||||||
|
- Angenommener Vorschlag landet in der Datei
|
||||||
|
- Übrige Vorschläge bleiben stehen
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- Prüfung durchlaufen lassen
|
- Prüfung durchlaufen lassen
|
||||||
- Unter den fehlerhaften Problemen werden Verbesserungsvorschläge angezeigt
|
- Unter den fehlerhaften Problemen werden Verbesserungsvorschläge angezeigt
|
||||||
- Annehmen und Ablehnen Buttons angezeigt
|
- Beide Icons angezeigt
|
||||||
|
<!-- funktion -->
|
||||||
|
Badge prüfen
|
||||||
|
<!-- ziel -->
|
||||||
|
Prüft die Einstufung als Kern oder Optional
|
||||||
|
<!-- regeln -->
|
||||||
|
- Nur kern und optional erlaubt
|
||||||
|
- Einstufung muss zum Ziel passen
|
||||||
|
- Falscher Badge wird rot markiert
|
||||||
|
<!-- testen -->
|
||||||
|
- Prüfung durchlaufen lassen
|
||||||
|
- Badge der Karte rot markiert
|
||||||
|
- Vorschlag unter dem Titel angezeigt
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Fehlerhafte Cards markieren
|
Fehlerhafte Cards markieren
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
@@ -33,10 +48,39 @@ Fehlerhafte Cards markieren
|
|||||||
Prüfung ausführen
|
Prüfung ausführen
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Prüf-Icon in Sidebar Navigation
|
- Prüf-Icon in Sidebar Navigation
|
||||||
-
|
|
||||||
<!-- testen -->
|
<!-- testen -->
|
||||||
- LMB auf Icon
|
- LMB auf Icon
|
||||||
- Prüfung wird gestartet
|
- Prüfung wird gestartet
|
||||||
- Rechte Sidebar geöffnet
|
- Rechte Sidebar geöffnet
|
||||||
- Fortschritt unter Icon angezeigt
|
- Fortschritt unter Icon angezeigt
|
||||||
- Probleme werden live gelistet
|
- Probleme werden live gelistet
|
||||||
|
<!-- funktion -->
|
||||||
|
Stellen einzeln prüfen
|
||||||
|
<!-- ziel -->
|
||||||
|
Jede Stelle bekommt eigene Vorgaben und einen eigenen Ausschnitt
|
||||||
|
<!-- regeln -->
|
||||||
|
- Eine Anfrage je Kartenebene
|
||||||
|
- Eine Anfrage je Funktion
|
||||||
|
- Kartenebene prüft Titel, Badge, Ziel, Regeln
|
||||||
|
- Funktion prüft Titel, Ziel, Regeln, Testen
|
||||||
|
- Prüfer sieht nur seinen Ausschnitt
|
||||||
|
- Herkunft eines Problems stammt aus dem Code
|
||||||
|
<!-- testen -->
|
||||||
|
- Prüfung starten
|
||||||
|
- Fortschritt beobachten
|
||||||
|
- Probleme mit Herkunft gelistet
|
||||||
|
<!-- funktion -->
|
||||||
|
Stil-Katalog ablegen
|
||||||
|
<!-- ziel -->
|
||||||
|
Trennt die Vorgaben je Feld voneinander
|
||||||
|
<!-- regeln -->
|
||||||
|
- Eine Datei je Feld im Ordner stil
|
||||||
|
- allgemein gilt für beide Prüfer
|
||||||
|
- feature-Präfix gilt für die Kartenebene
|
||||||
|
- funktion-Präfix gilt für die Funktionen
|
||||||
|
- Neue Datei wirkt ohne Codeänderung
|
||||||
|
- Leerer Ordner bricht die Prüfung ab
|
||||||
|
<!-- testen -->
|
||||||
|
- Datei im Ordner stil ergänzen
|
||||||
|
- Prüfung starten
|
||||||
|
- Neue Vorgabe wirkt
|
||||||
|
|||||||
@@ -12,28 +12,36 @@ kern
|
|||||||
- Feld leer lassen statt füllen
|
- Feld leer lassen statt füllen
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Regel Schreibstil
|
Badge einstufen
|
||||||
|
<!-- ziel -->
|
||||||
|
Stuft die Karte als Kern oder Optional ein
|
||||||
|
<!-- regeln -->
|
||||||
|
- Nur kern oder optional
|
||||||
|
- kern wenn das Projekt es braucht
|
||||||
|
- Im Zweifel kern
|
||||||
|
<!-- funktion -->
|
||||||
|
Regel schreiben
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Spezifische Hinweise zum Titel
|
Spezifische Hinweise zum Titel
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Ein kurzer Satz je Regel
|
- Ein kurzer Satz je Regel
|
||||||
- Eine Aussage je Zeile
|
- Eine Aussage je Zeile
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Testschritt Schreibstil
|
Testschritt schreiben
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Beschreibt das Prüfen als Ablauf
|
Beschreibt das Prüfen als Ablauf
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Ein Schritt je Zeile
|
- Ein Schritt je Zeile
|
||||||
- Letzte Zeile beschreibt Endzustand
|
- Letzte Zeile beschreibt Endzustand
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Titel Schreibstil
|
Titel schreiben
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Benennt die Sache so kompakt wie möglich
|
Benennt die Sache so kompakt wie möglich
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
- Nur wenige Wörter
|
- Nur wenige Wörter
|
||||||
- Kurzsatz bei Funktionen
|
- Kurzsatz bei Funktionen
|
||||||
<!-- funktion -->
|
<!-- funktion -->
|
||||||
Ziel Schreibstil
|
Ziel schreiben
|
||||||
<!-- ziel -->
|
<!-- ziel -->
|
||||||
Beschreibt kurz den Kern
|
Beschreibt kurz den Kern
|
||||||
<!-- regeln -->
|
<!-- regeln -->
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import SeitenLeiste from './components/SeitenLeiste.vue'
|
|||||||
import FeatureListe from './components/FeatureListe.vue'
|
import FeatureListe from './components/FeatureListe.vue'
|
||||||
import KontextMenue from './components/KontextMenue.vue'
|
import KontextMenue from './components/KontextMenue.vue'
|
||||||
import PruefPanel from './components/PruefPanel.vue'
|
import PruefPanel from './components/PruefPanel.vue'
|
||||||
import { offen as pruefungOffen, starte as startePruefung, schliesse as schliessePruefung } from './composables/pruefung.js'
|
import {
|
||||||
|
offen as pruefungOffen,
|
||||||
|
laeuft as pruefungLaeuft,
|
||||||
|
prozent as pruefungProzent,
|
||||||
|
starte as startePruefung,
|
||||||
|
schliesse as schliessePruefung,
|
||||||
|
} from './composables/pruefung.js'
|
||||||
|
|
||||||
const projekte = ref([])
|
const projekte = ref([])
|
||||||
const aktivesProjekt = ref(null)
|
const aktivesProjekt = ref(null)
|
||||||
@@ -80,12 +86,10 @@ onMounted(async () => {
|
|||||||
@geaendert="ladeProjekte"
|
@geaendert="ladeProjekte"
|
||||||
@umbenannt="umbenannt"
|
@umbenannt="umbenannt"
|
||||||
>
|
>
|
||||||
<button
|
<span v-if="aktivesProjekt" class="pruefen">
|
||||||
v-if="aktivesProjekt"
|
<button class="icon-knopf" title="Karten prüfen" @click="startePruefung(aktivesProjekt)">⌾</button>
|
||||||
class="icon-knopf"
|
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
|
||||||
title="Karten prüfen"
|
</span>
|
||||||
@click="startePruefung(aktivesProjekt)"
|
|
||||||
>⌾</button>
|
|
||||||
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
|
<button class="icon-knopf" :title="TITEL[modus]" @click="wechsleDarkMode">
|
||||||
{{ ICONS[modus] }}
|
{{ ICONS[modus] }}
|
||||||
</button>
|
</button>
|
||||||
@@ -118,4 +122,15 @@ main {
|
|||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
/* Fortschritt sitzt unter dem Prüf-Icon */
|
||||||
|
.pruefen {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.fortschritt {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--dezent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { nextTick, ref, watch } from 'vue'
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { featureSpeichern } from '../api.js'
|
import { featureSpeichern } from '../api.js'
|
||||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||||
import { erledige } from '../composables/pruefung.js'
|
import { benenneUm, erledige, vorschlagsArt, vorschlagsZeilen } from '../composables/pruefung.js'
|
||||||
import ModalBox from './ModalBox.vue'
|
import ModalBox from './ModalBox.vue'
|
||||||
import ProblemHinweis from './ProblemHinweis.vue'
|
import ProblemHinweis from './ProblemHinweis.vue'
|
||||||
|
|
||||||
@@ -154,9 +154,13 @@ function baueRoh() {
|
|||||||
|
|
||||||
async function speichern() {
|
async function speichern() {
|
||||||
meldung.value = ''
|
meldung.value = ''
|
||||||
|
const alt = props.feature.id
|
||||||
try {
|
try {
|
||||||
const f = await featureSpeichern(props.projekt, props.feature.id, baueRoh())
|
const f = await featureSpeichern(props.projekt, alt, baueRoh())
|
||||||
emit('aktualisiert', props.feature.id, f) // id kann sich durch Titeländerung ändern
|
// Ein neuer Titel benennt die Datei um; die offenen Probleme müssen mitwandern,
|
||||||
|
// sonst findet problemeFuer() sie unter der neuen id nicht mehr.
|
||||||
|
if (f.id !== alt) benenneUm(alt, 'feature', alt, f.id)
|
||||||
|
emit('aktualisiert', alt, f)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
meldung.value = e.message
|
meldung.value = e.message
|
||||||
}
|
}
|
||||||
@@ -187,27 +191,53 @@ function problemeAn(feld, funktion = '', original = null) {
|
|||||||
(original === null || p.original === original))
|
(original === null || p.original === original))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorschlag übernehmen: Titel und Ziel ganz ersetzen, bei Regeln und Testen die Zeile
|
// Bleibt die alte Zeile im Vorschlag stehen, kommt nur etwas dazu — sie ist in Ordnung
|
||||||
|
function fehlerAn(feld, funktion = '', original = null) {
|
||||||
|
return problemeAn(feld, funktion, original).some(p => vorschlagsArt(p) !== 'ergaenzen')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Vorschlag ersetzt die gefundene Zeile durch seine eigenen — keine, eine oder
|
||||||
|
// mehrere. Gesucht wird über den Text, nicht über den Index: bei mehreren Vorschlägen
|
||||||
|
// hintereinander hat sich die Liste schon verschoben.
|
||||||
|
function zeileAendern(text, p) {
|
||||||
|
const zeilen = text.split('\n')
|
||||||
|
const i = zeilen.findIndex(z => z.replace(/^[-*]\s*/, '').trim() === p.original)
|
||||||
|
if (i < 0) return null
|
||||||
|
zeilen.splice(i, 1, ...vorschlagsZeilen(p).map(z => `- ${z}`))
|
||||||
|
return zeilen.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschlag übernehmen. Rückgabe false = nicht anwendbar, dann bleibt er stehen
|
||||||
function anwenden(p) {
|
function anwenden(p) {
|
||||||
const ziel = p.funktion ? s.value.funktionen.find(f => f.text === p.funktion) : s.value
|
const ziel = p.funktion ? s.value.funktionen.find(f => f.text === p.funktion) : s.value
|
||||||
if (!ziel) return
|
if (!ziel) return false
|
||||||
if (p.feld === 'titel') {
|
if (p.feld === 'titel') {
|
||||||
if (p.funktion) ziel.text = p.vorschlag
|
if (!p.vorschlag) return false
|
||||||
else ziel.titel = p.vorschlag
|
if (p.funktion) {
|
||||||
|
ziel.text = p.vorschlag
|
||||||
|
// Die übrigen Probleme dieser Funktion zeigen noch auf den alten Titel
|
||||||
|
benenneUm(props.feature.id, 'funktion', p.funktion, p.vorschlag)
|
||||||
|
} else {
|
||||||
|
ziel.titel = p.vorschlag
|
||||||
|
}
|
||||||
} else if (p.feld === 'ziel') {
|
} else if (p.feld === 'ziel') {
|
||||||
ziel.ziel = p.vorschlag
|
ziel.ziel = p.vorschlag // leerer Vorschlag leert das Feld — der Katalog erlaubt das
|
||||||
|
} else if (p.feld === 'badge') {
|
||||||
|
if (p.vorschlag !== 'kern' && p.vorschlag !== 'optional') return false
|
||||||
|
ziel.art = p.vorschlag
|
||||||
|
} else if (p.feld === 'regeln' || p.feld === 'testen') {
|
||||||
|
if (typeof ziel[p.feld] !== 'string') return false // z. B. testen auf Kartenebene
|
||||||
|
const neu = zeileAendern(ziel[p.feld], p)
|
||||||
|
if (neu === null) return false // Zeile schon weg oder umgeschrieben
|
||||||
|
ziel[p.feld] = neu
|
||||||
} else {
|
} else {
|
||||||
const feld = p.feld === 'testen' ? 'testen' : 'regeln'
|
return false // unbekanntes Feld — lieber nichts anfassen
|
||||||
ziel[feld] = ziel[feld]
|
|
||||||
.split('\n')
|
|
||||||
.map(z => (z.replace(/^[-*]\s*/, '').trim() === p.original ? `- ${p.vorschlag}` : z))
|
|
||||||
.join('\n')
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function annehmen(p) {
|
async function annehmen(p) {
|
||||||
anwenden(p)
|
if (anwenden(p)) erledige(p)
|
||||||
erledige(p)
|
|
||||||
await speichern()
|
await speichern()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +246,7 @@ function ablehnen(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function alleAnnehmen() {
|
async function alleAnnehmen() {
|
||||||
props.probleme.forEach(p => { anwenden(p); erledige(p) })
|
props.probleme.forEach(p => { if (anwenden(p)) erledige(p) })
|
||||||
await speichern()
|
await speichern()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +277,15 @@ function alleAblehnen() {
|
|||||||
<button
|
<button
|
||||||
v-if="gross"
|
v-if="gross"
|
||||||
class="badge"
|
class="badge"
|
||||||
:class="s.art === 'kern' ? 'blau' : 'gruen'"
|
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
|
||||||
title="Klicken zum Umschalten"
|
title="Klicken zum Umschalten"
|
||||||
@click="wechsleArt"
|
@click="wechsleArt"
|
||||||
>{{ s.art }}</button>
|
>{{ s.art }}</button>
|
||||||
<span v-else class="badge" :class="s.art === 'kern' ? 'blau' : 'gruen'">{{ s.art }}</span>
|
<span
|
||||||
|
v-else
|
||||||
|
class="badge"
|
||||||
|
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
|
||||||
|
>{{ s.art }}</span>
|
||||||
<button
|
<button
|
||||||
class="icon-knopf"
|
class="icon-knopf"
|
||||||
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
||||||
@@ -263,6 +297,7 @@ function alleAblehnen() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<ProblemHinweis :probleme="problemeAn('titel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
<ProblemHinweis :probleme="problemeAn('titel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
|
<ProblemHinweis :probleme="problemeAn('badge')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||||
|
|
||||||
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
||||||
<p class="gruppe">Ziel</p>
|
<p class="gruppe">Ziel</p>
|
||||||
@@ -292,7 +327,7 @@ function alleAblehnen() {
|
|||||||
/>
|
/>
|
||||||
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
||||||
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
|
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
|
||||||
<div class="regel" :class="{ fehler: problemeAn('regeln', '', r).length }">{{ r }}</div>
|
<div class="regel" :class="{ fehler: fehlerAn('regeln', '', r) }">{{ r }}</div>
|
||||||
<ProblemHinweis
|
<ProblemHinweis
|
||||||
:probleme="problemeAn('regeln', '', r)"
|
:probleme="problemeAn('regeln', '', r)"
|
||||||
@annehmen="annehmen"
|
@annehmen="annehmen"
|
||||||
@@ -326,7 +361,7 @@ function alleAblehnen() {
|
|||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="funktionstitel klickbar"
|
class="funktionstitel klickbar"
|
||||||
:class="{ fehler: problemeAn('titel', f.text).length }"
|
:class="{ fehler: fehlerAn('titel', f.text) }"
|
||||||
@click="starte('funktion', i)"
|
@click="starte('funktion', i)"
|
||||||
>{{ f.text }}</div>
|
>{{ f.text }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,7 +398,7 @@ function alleAblehnen() {
|
|||||||
/>
|
/>
|
||||||
<div v-else class="klickbar" @click="starte('regeln', i)">
|
<div v-else class="klickbar" @click="starte('regeln', i)">
|
||||||
<template v-for="(r, k) in punkte(f.regeln)" :key="k">
|
<template v-for="(r, k) in punkte(f.regeln)" :key="k">
|
||||||
<div class="regel" :class="{ fehler: problemeAn('regeln', f.text, r).length }">{{ r }}</div>
|
<div class="regel" :class="{ fehler: fehlerAn('regeln', f.text, r) }">{{ r }}</div>
|
||||||
<ProblemHinweis
|
<ProblemHinweis
|
||||||
:probleme="problemeAn('regeln', f.text, r)"
|
:probleme="problemeAn('regeln', f.text, r)"
|
||||||
@annehmen="annehmen"
|
@annehmen="annehmen"
|
||||||
@@ -387,7 +422,7 @@ function alleAblehnen() {
|
|||||||
/>
|
/>
|
||||||
<div v-else class="klickbar" @click="starte('testen', i)">
|
<div v-else class="klickbar" @click="starte('testen', i)">
|
||||||
<template v-for="(schritt, k) in punkte(f.testen)" :key="k">
|
<template v-for="(schritt, k) in punkte(f.testen)" :key="k">
|
||||||
<div class="schritt" :class="{ fehler: problemeAn('testen', f.text, schritt).length }">
|
<div class="schritt" :class="{ fehler: fehlerAn('testen', f.text, schritt) }">
|
||||||
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
||||||
</div>
|
</div>
|
||||||
<ProblemHinweis
|
<ProblemHinweis
|
||||||
@@ -431,6 +466,7 @@ function alleAblehnen() {
|
|||||||
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
||||||
.karte.fehlerhaft { border-color: var(--rot); }
|
.karte.fehlerhaft { border-color: var(--rot); }
|
||||||
.fehler { color: var(--rot); }
|
.fehler { color: var(--rot); }
|
||||||
|
.badge.fehler { color: var(--rot); background: var(--rot-bg); }
|
||||||
.alle { white-space: nowrap; }
|
.alle { white-space: nowrap; }
|
||||||
|
|
||||||
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
import { kontextMenue, schliesseMenue } from '../composables/kontextmenue.js'
|
import { kontextMenue, schliesseMenue } from '../composables/kontextmenue.js'
|
||||||
|
|
||||||
|
const ABSTAND = 4
|
||||||
|
|
||||||
|
const menue = ref(null)
|
||||||
|
const pos = ref({ left: '0px', top: '0px' })
|
||||||
|
|
||||||
|
// Erst an der Mausposition zeichnen, dann anhand der gemessenen Größe ins Fenster rücken
|
||||||
|
watch(kontextMenue, async (m) => {
|
||||||
|
if (!m) return
|
||||||
|
pos.value = { left: `${m.x}px`, top: `${m.y}px` }
|
||||||
|
await nextTick()
|
||||||
|
const el = menue.value
|
||||||
|
if (!el) return
|
||||||
|
const { width, height } = el.getBoundingClientRect()
|
||||||
|
const x = Math.min(m.x, window.innerWidth - width - ABSTAND)
|
||||||
|
const y = Math.min(m.y, window.innerHeight - height - ABSTAND)
|
||||||
|
pos.value = { left: `${Math.max(ABSTAND, x)}px`, top: `${Math.max(ABSTAND, y)}px` }
|
||||||
|
})
|
||||||
|
|
||||||
function waehle(eintrag) {
|
function waehle(eintrag) {
|
||||||
schliesseMenue()
|
schliesseMenue()
|
||||||
eintrag.aktion()
|
eintrag.aktion()
|
||||||
@@ -8,11 +27,7 @@ function waehle(eintrag) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div v-if="kontextMenue" ref="menue" class="kontextmenue" :style="pos">
|
||||||
v-if="kontextMenue"
|
|
||||||
class="kontextmenue"
|
|
||||||
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
|
|
||||||
>
|
|
||||||
<button v-for="e in kontextMenue.eintraege" :key="e.text" @click="waehle(e)">{{ e.text }}</button>
|
<button v-for="e in kontextMenue.eintraege" :key="e.text" @click="waehle(e)">{{ e.text }}</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
titel: { type: String, required: true },
|
titel: { type: String, required: true },
|
||||||
@@ -18,7 +18,16 @@ function bestaetigen() {
|
|||||||
emit('bestaetigt', wert.value.trim())
|
emit('bestaetigt', wert.value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => feld.value?.focus())
|
// Am Fenster statt am Eingabefeld: die Rückfrage hat gar keins
|
||||||
|
function beiTaste(e) {
|
||||||
|
if (e.key === 'Escape') emit('abbrechen')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
feld.value?.focus()
|
||||||
|
window.addEventListener('keydown', beiTaste)
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', beiTaste))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,7 +41,6 @@ onMounted(() => feld.value?.focus())
|
|||||||
v-model="wert"
|
v-model="wert"
|
||||||
:placeholder="platzhalter"
|
:placeholder="platzhalter"
|
||||||
@keyup.enter="bestaetigen"
|
@keyup.enter="bestaetigen"
|
||||||
@keydown.esc="emit('abbrechen')"
|
|
||||||
>
|
>
|
||||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { vorschlagsArt } from '../composables/pruefung.js'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
probleme: { type: Array, required: true },
|
probleme: { type: Array, required: true },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['annehmen', 'ablehnen'])
|
const emit = defineEmits(['annehmen', 'ablehnen'])
|
||||||
|
|
||||||
|
// Was der Vorschlag bewirkt, steht nicht in der Antwort — es ergibt sich aus dem
|
||||||
|
// Vergleich mit der alten Zeile. Bei entfernen steht dort die Zeile, die weggeht.
|
||||||
|
const ZEICHEN = { ergaenzen: '+', entfernen: '−' }
|
||||||
|
const zeichen = (p) => ZEICHEN[vorschlagsArt(p)] || ''
|
||||||
|
const text = (p) => (vorschlagsArt(p) === 'entfernen' ? p.original : p.vorschlag)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -14,7 +22,9 @@ const emit = defineEmits(['annehmen', 'ablehnen'])
|
|||||||
<button class="icon-knopf rot" title="Ablehnen" @click="emit('ablehnen', p)">✕</button>
|
<button class="icon-knopf rot" title="Ablehnen" @click="emit('ablehnen', p)">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="vorschlag">{{ p.vorschlag }}</p>
|
<p class="vorschlag" :class="vorschlagsArt(p)">
|
||||||
|
<span v-if="zeichen(p)" class="zeichen">{{ zeichen(p) }}</span>{{ text(p) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,5 +58,13 @@ const emit = defineEmits(['annehmen', 'ablehnen'])
|
|||||||
margin: 0.1rem 0 0;
|
margin: 0.1rem 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-line; /* mehrzeilige Vorschläge bleiben mehrzeilig */
|
||||||
|
}
|
||||||
|
.vorschlag.entfernen { text-decoration: line-through; color: var(--dezent); }
|
||||||
|
.zeichen {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '.
|
|||||||
|
|
||||||
<p class="fortschritt">
|
<p class="fortschritt">
|
||||||
<template v-if="laeuft">{{ prozent }} % geprüft</template>
|
<template v-if="laeuft">{{ prozent }} % geprüft</template>
|
||||||
<template v-else-if="gesamt">{{ gesamt }} Karten geprüft</template>
|
<template v-else-if="gesamt">{{ gesamt }} Stellen geprüft</template>
|
||||||
<template v-else>Keine Karten</template>
|
<template v-else>Nichts zu prüfen</template>
|
||||||
<span v-if="laeuft" class="dezent"> ({{ fertig }}/{{ gesamt }})</span>
|
<span v-if="laeuft" class="dezent"> ({{ fertig }}/{{ gesamt }})</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -22,8 +22,9 @@ import { laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '.
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="(p, i) in probleme" :key="i">
|
<li v-for="(p, i) in probleme" :key="i">
|
||||||
<span class="stelle">{{ p.funktion || p.feature }} · {{ p.feld }}</span>
|
<span class="stelle">{{ p.feature }}<template v-if="p.funktion"> · {{ p.funktion }}</template> · {{ p.feld }}</span>
|
||||||
<span class="problem">{{ p.problem }}</span>
|
<span class="problem">{{ p.problem }}</span>
|
||||||
|
<span class="original">{{ p.original }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -69,4 +70,9 @@ li {
|
|||||||
color: var(--akzent);
|
color: var(--akzent);
|
||||||
}
|
}
|
||||||
.problem { font-size: 0.85rem; }
|
.problem { font-size: 0.85rem; }
|
||||||
|
.original {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--dezent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const gesamt = ref(0)
|
|||||||
export const meldung = ref('')
|
export const meldung = ref('')
|
||||||
export const probleme = ref([])
|
export const probleme = ref([])
|
||||||
|
|
||||||
const erledigt = new Set() // angenommene oder abgelehnte Probleme kommen nicht zurück
|
const gesehen = new Set() // schon übernommene Probleme holt der nächste Abruf nicht erneut
|
||||||
let projektName = null
|
let projektName = null
|
||||||
let uhr = null
|
let uhr = null
|
||||||
|
|
||||||
@@ -25,12 +25,41 @@ export function problemeFuer(featureId) {
|
|||||||
return probleme.value.filter(p => p.feature === featureId)
|
return probleme.value.filter(p => p.feature === featureId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der Vorschlag ersetzt die Stelle eins zu eins. Was das bedeutet, steht nicht in der
|
||||||
|
// Antwort — es ergibt sich aus dem Vergleich. So muss das Modell keine Aktion wählen.
|
||||||
|
export function vorschlagsZeilen(p) {
|
||||||
|
return (p.vorschlag || '').split('\n').map(z => z.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vorschlagsArt(p) {
|
||||||
|
const zeilen = vorschlagsZeilen(p)
|
||||||
|
if (!zeilen.length) return 'entfernen'
|
||||||
|
// Steht die alte Zeile wieder vorn, kommt nur etwas dazu
|
||||||
|
if (zeilen.length > 1 && zeilen[0] === p.original) return 'ergaenzen'
|
||||||
|
return 'anpassen'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Probleme werden angehängt, nicht ersetzt. Der Server kennt nur die Namen von
|
||||||
|
// vor dem Lauf; würde die Liste jede Sekunde neu gesetzt, verlöre benenneUm() seine Wirkung.
|
||||||
function uebernehmen(stand) {
|
function uebernehmen(stand) {
|
||||||
laeuft.value = stand.laeuft
|
laeuft.value = stand.laeuft
|
||||||
fertig.value = stand.fertig
|
fertig.value = stand.fertig
|
||||||
gesamt.value = stand.gesamt
|
gesamt.value = stand.gesamt
|
||||||
meldung.value = stand.meldung
|
meldung.value = stand.meldung
|
||||||
probleme.value = stand.probleme.filter(p => !erledigt.has(schluessel(p)))
|
for (const p of stand.probleme) {
|
||||||
|
const k = schluessel(p)
|
||||||
|
if (gesehen.has(k)) continue
|
||||||
|
gesehen.add(k)
|
||||||
|
probleme.value.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein angenommener Titelvorschlag benennt Karte oder Funktion um. Die übrigen Probleme
|
||||||
|
// zeigen dann noch auf den alten Namen und wären in der Karte nicht mehr auffindbar.
|
||||||
|
export function benenneUm(featureId, feld, alt, neu) {
|
||||||
|
probleme.value.forEach(p => {
|
||||||
|
if (p.feature === featureId && p[feld] === alt) p[feld] = neu
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function abfragen() {
|
async function abfragen() {
|
||||||
@@ -52,7 +81,7 @@ export async function starte(projekt) {
|
|||||||
projektName = projekt
|
projektName = projekt
|
||||||
offen.value = true
|
offen.value = true
|
||||||
meldung.value = ''
|
meldung.value = ''
|
||||||
erledigt.clear()
|
gesehen.clear()
|
||||||
probleme.value = []
|
probleme.value = []
|
||||||
try {
|
try {
|
||||||
uebernehmen(await pruefungStarten(projekt))
|
uebernehmen(await pruefungStarten(projekt))
|
||||||
@@ -74,10 +103,10 @@ export async function starte(projekt) {
|
|||||||
uhr = setInterval(abfragen, 1000)
|
uhr = setInterval(abfragen, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen
|
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen.
|
||||||
|
// Vergleich über die Identität: der Schlüssel kann sich durch benenneUm() ändern.
|
||||||
export function erledige(problem) {
|
export function erledige(problem) {
|
||||||
erledigt.add(schluessel(problem))
|
probleme.value = probleme.value.filter(p => p !== problem)
|
||||||
probleme.value = probleme.value.filter(p => schluessel(p) !== schluessel(problem))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function schliesse() {
|
export async function schliesse() {
|
||||||
|
|||||||
Reference in New Issue
Block a user