Files
planer2/backend/inhalt.py
2026-07-28 12:31:42 +02:00

749 lines
32 KiB
Python

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