749 lines
32 KiB
Python
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)
|