357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
|
|
|
|
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code?
|
|
**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.
|
|
"""
|
|
|
|
import asyncio
|
|
import difflib
|
|
import re
|
|
|
|
import agents
|
|
import config
|
|
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 = """An dieser Stelle kann eine Person etwas auslösen:
|
|
|
|
{art} in {datei}, Zeile {zeile}
|
|
|
|
```
|
|
{umfeld}
|
|
```
|
|
{rumpf}
|
|
Die Karten des Projekts beschreiben bisher das hier:
|
|
|
|
{funktionen}
|
|
|
|
Beschreibt eine dieser Funktionen, was an der Stelle oben passiert?
|
|
Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte
|
|
einer Funktion gehören mit zu dem, was sie abdeckt.
|
|
|
|
Antworte ausschließlich mit JSON:
|
|
{{"abgedeckt": true, "funktion": "..."}}
|
|
oder
|
|
{{"abgedeckt": false, "karte": "...", "titel": "..."}}
|
|
|
|
- funktion: der bestehende Funktionstitel, wörtlich, wenn abgedeckt
|
|
- karte: der Titel der Karte, in die die Fähigkeit thematisch gehört
|
|
- titel: wie die neue Funktion heißen soll — Objekt und Verb im Infinitiv,
|
|
höchstens 5 Wörter
|
|
|
|
Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines
|
|
Feldes, Abbrechen mit Escape, Gesundheitsprüfungen, interne Weiterleitung.
|
|
Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen."""
|
|
|
|
# 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
|
|
# Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
|
|
GLEICH = 0.75
|
|
|
|
|
|
def _normal(text: str) -> str:
|
|
return re.sub(r"[^a-zä-ü0-9 ]", "", (text or "").lower()).strip()
|
|
|
|
|
|
def _aehnlich(a: str, b: str) -> float:
|
|
return difflib.SequenceMatcher(None, _normal(a), _normal(b)).ratio()
|
|
|
|
|
|
def _json(antwort: str) -> dict:
|
|
import pruefung # zur Laufzeit, sonst Ringschluss
|
|
|
|
return pruefung._json_lesen(antwort)
|
|
|
|
|
|
# ---------------------------------------------------------------- Feature im Code
|
|
|
|
def _funktionstext(karte: str, f: dict) -> str:
|
|
"""Alles, was die Funktion behauptet — danach wird im Code gesucht."""
|
|
zeilen = [f"Karte: {karte}", 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)
|
|
|
|
|
|
async def feature_im_code(features: list, quelle: dict, quelltext: str,
|
|
zaehl=None) -> list:
|
|
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet."""
|
|
auftraege = [(f, x) for f in features for x in f["sektionen"]["funktionen"]]
|
|
|
|
async def eine(paar):
|
|
feature, x = paar
|
|
nachricht = SUCHE_NACHRICHT.format(
|
|
quelle=quelltext,
|
|
funktion=_funktionstext(feature["sektionen"]["titel"], x))
|
|
proben = await asyncio.gather(*(
|
|
_urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN
|
|
), return_exceptions=True)
|
|
|
|
if zaehl:
|
|
zaehl()
|
|
gruende = []
|
|
brauchbar = belegt = behauptet = 0
|
|
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
|
|
if quellcode.belegt(quelle, str(u.get("datei") or "").strip(),
|
|
str(u.get("beleg") or "").strip()):
|
|
belegt += 1
|
|
elif u.get("grund"):
|
|
gruende.append(str(u["grund"]).strip())
|
|
|
|
if belegt:
|
|
return None # geprüfter Beleg: die Funktion steht im Code
|
|
if behauptet:
|
|
return None # widersprüchliche Stimmen: nichts behaupten
|
|
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")
|
|
return {"feature": feature["id"], "titel": x["text"],
|
|
"grund": gruende[0] if gruende else "Kein Beleg im Code gefunden"}
|
|
|
|
return [x for x in await asyncio.gather(*(eine(a) for a in auftraege)) if x]
|
|
|
|
|
|
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 = [x for x in ohne_code if x["feature"] == f["id"]]
|
|
if not betroffen:
|
|
continue
|
|
weg = [x["titel"] for x in betroffen]
|
|
neu = [t for t in ist if t not in weg]
|
|
grund = (f"{len(weg)} Funktion ohne Code" if len(weg) == 1
|
|
else f"{len(weg)} Funktionen ohne Code")
|
|
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.
|
|
"""
|
|
teile = []
|
|
for f in features:
|
|
s = f["sektionen"]
|
|
block = [f'## Karte: {s["titel"]}']
|
|
if s["ziel"]:
|
|
block.append(f'Ziel: {s["ziel"]}')
|
|
for x in s["funktionen"]:
|
|
block.append(f'\n### {x["text"]} (Karte: {s["titel"]})')
|
|
if x["ziel"]:
|
|
block.append(f'Ziel: {x["ziel"]}')
|
|
for feld in ("regeln", "testen"):
|
|
if x[feld]:
|
|
zeilen = ", ".join(z.strip("- ").strip()
|
|
for z in x[feld].splitlines() if z.strip())
|
|
block.append(f'{feld.capitalize()}: {zeilen}')
|
|
teile.append("\n".join(block))
|
|
return "\n\n".join(teile) or "—"
|
|
|
|
|
|
async def _abdeckung(punkt: dict, liste: str) -> list:
|
|
rumpf = f'\nDahinter steht:\n\n```\n{punkt["rumpf"]}\n```\n' if punkt["rumpf"] else ""
|
|
nachricht = ABDECKUNG.format(funktionen=liste, art=punkt["art"],
|
|
datei=punkt["datei"], zeile=punkt["zeile"],
|
|
umfeld=punkt["umfeld"], rumpf=rumpf)
|
|
proben = await asyncio.gather(*(
|
|
_urteil(ABDECKUNG_SYSTEM, nachricht, t) for t in URTEIL_TEMPERATUREN
|
|
), return_exceptions=True)
|
|
return [p for p in proben if isinstance(p, dict)]
|
|
|
|
|
|
def _naechste_karte(name: str, karten: dict):
|
|
if not name:
|
|
return None
|
|
treffer = max(karten, key=lambda k: _aehnlich(name, k))
|
|
return karten[treffer] if _aehnlich(name, treffer) > 0.6 else None
|
|
|
|
|
|
async def code_im_feature(features: list, punkte: list, zaehl=None) -> list:
|
|
"""Bedienstellen, die von keiner Funktion abgedeckt sind."""
|
|
liste = _funktionsliste(features)
|
|
karten = {f["sektionen"]["titel"]: f for f in features}
|
|
|
|
async def eine(punkt):
|
|
urteile = await _abdeckung(punkt, liste)
|
|
if zaehl:
|
|
zaehl()
|
|
if len(urteile) < FEHLT_AB:
|
|
# Nicht verschweigen: ausgefallene Stimmen sähen sonst aus wie „ist
|
|
# abgedeckt" und der Lauf meldete stillschweigend zu wenig. Ein
|
|
# einzelner Ausfall darf den Lauf aber nicht abreißen — es zählt, ob
|
|
# noch genug Stimmen übrig sind, um die Schwelle zu erreichen.
|
|
raise RuntimeError(
|
|
f'{punkt["schluessel"]}: nur {len(urteile)} von '
|
|
f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar")
|
|
if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB:
|
|
return None
|
|
ziele = [str(u.get("karte") or "").strip() for u in urteile]
|
|
name = max(set(ziele), key=ziele.count)
|
|
treffer = karten.get(name) or _naechste_karte(name, karten)
|
|
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
|
|
if not treffer or not titel:
|
|
return None
|
|
return {"feature": treffer["id"],
|
|
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
|
|
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
|
|
|
|
gefunden = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x]
|
|
# 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(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(features: list, quelle: dict, quelltext: str,
|
|
zaehl=None) -> list:
|
|
ohne = await feature_im_code(features, quelle, quelltext, zaehl)
|
|
return probleme_feature(features, ohne)
|
|
|
|
|
|
async def pruefe_code_im_feature(features: list, quelle: dict, zaehl=None) -> list:
|
|
punkte = quellcode.interaktionen(quelle)
|
|
if not punkte:
|
|
raise RuntimeError("Keine Bedienstellen im Quelltext gefunden")
|
|
return probleme_code(features, await code_im_feature(features, punkte, zaehl))
|