Files
Masterarbeit/.claude/skills/run-experiment/analyse-anforderungen.py
T
Christoph SchwörerandClaude Opus 5 28e927013b LM-Studio-Matrix mit gemma-4-e4b: zwoelf Laeufe und vier Befunde
Skill 12.0.0 bis 12.2.0 und Adapter 2.0.0 bis 2.2.0.

Werkzeugfreigabe: Die Shell-Rechte des OpenCode-Adapters sind jetzt eine
Denylist wie beim Claude-Adapter statt einer Allowlist. Ausloeser war der
erste Qwen-Lauf, dessen einzige beide Werkzeugaufrufe an einer Pipeline
scheiterten. Kontrolltest belegt beide Richtungen: Get-ChildItem | Format-Table
laeuft durch, rm wird verweigert, die Datei bleibt bestehen.

Lokaler Betrieb: Der Preflight entlaedt alle Modelle vor jedem Lauf und laedt
den Kontext auf das Modellmaximum. Gemessen waren zuvor beide Modelle
gleichzeitig geladen - 168 MiB frei von 16,3 GB. Qwen 27B passt auf dieser
Karte nicht und wurde durch qwen3.5-9b ersetzt, in Q4_K_M wie Gemma.

Messinstrument: analyse-anforderungen.py erkennt Feldnamen in Markdown-
Fettschrift und Modulpraefixe in IDs. Der erste lokale Lauf mit Artefakten
wurde sonst mit null Anforderungen gezaehlt statt mit neun. Regressionsprobe
an vier Claude-Laeufen unveraendert.

Befunde: Der Standard-Ausgabeblock, mit dem Claude sieben Artefakte erzeugt,
liefert bei gemma-4-e4b null von sechs Laeufen ein Ergebnis - das Modell loest
den Pfad relativ zum Arbeitsverzeichnis auf. Ein Lauf startete alle sieben
vorgesehenen Rollen und lieferte neun formkonforme Anforderungen. Ein anderer
erzeugte sieben richtig benannte Dateien ohne eine einzige formkonforme
Anforderung. Und eine Shell-Umleitung schrieb an der Denylist vorbei in den
eingefrorenen Snapshot - gefunden vom Vorher/Nachher-Vergleich, nicht von der
Regel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:02:04 +02:00

264 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Wertet die erzeugten Anforderungen eines Laufs aus und schreibt einen Protokollabschnitt.
Aufruf: python analyse-anforderungen.py <laufverzeichnis> [--print]
Erzeugt `<laufverzeichnis>/_meta/anforderungen.md` (der fertige Abschnitt) und
`<laufverzeichnis>/_meta/anforderungen.json` (maschinenlesbar).
Geparst wird das im Prompt vorgegebene Blockformat (`ID:`, `Typ:`, `Belege:`, `Status:` ...).
"""
import io, json, os, re, sys, collections
EBENEN = ('StRS', 'SyRS', 'SwRS')
# Anforderungen, fuer die der Prompt mindestens einen PRIMAER-Beleg verlangt
RISIKO = re.compile(r'sicherheit|abrechnung|fakturier|berechtigung|recht|zugriff|authentifiz|'
r'passwort|rolle|lizenz|steuer|zahlung|mahn', re.I)
# Feldnamen in Markdown-Fettschrift, z. B. "**ID:** StRS-01" statt "ID: StRS-01".
# Modelle formatieren die Vorgabe des Prompts haeufig als Markdown aus; ohne
# Normalisierung bleibt eine vollstaendig korrekt gefuellte Datei unerkannt.
# Beobachtet am 01.09.2026 im Lauf Iteration 7/.../v12.1.0-001c: vier Dateien mit
# regelkonformen Bloecken wurden als "keine Anforderungen" gezaehlt.
FETTES_FELD = re.compile(r'(?m)^[ \t]*\*\*([^*:\n]{1,40}):\*\*[ \t]*')
def normalisiere(text):
"""Fettgedruckte Feldnamen auf die Klartextform des Prompts zuruecknehmen."""
return FETTES_FELD.sub(lambda m: m.group(1) + ': ', text)
def bloecke(pfad):
"""Zerlegt eine Anforderungsdatei in Bloecke ab jeder ID:-Zeile."""
if not os.path.exists(pfad):
return
text = normalisiere(io.open(pfad, encoding='utf-8').read())
teile = re.split(r'(?m)^ID:', text)
for t in teile[1:]:
yield 'ID:' + t
def feld(block, name):
m = re.search(r'(?m)^%s:[ \t]*(.*)$' % re.escape(name), block)
return m.group(1).strip() if m else ''
def ebene_von(block, aid, datei_ebene):
"""Ebene einer Anforderung bestimmen.
Die Ebene wird NICHT aus dem Dateinamen abgeleitet: Agenten legen Bloecke
regelmaessig in der Datei einer anderen Ebene ab (beobachtet im Lauf
Iteration 2/.../094250_v4.2.1-c69e: 12 StRS- und 5 SyRS-Bloecke standen in SwRS.md).
Massgeblich ist das Feld `Ebene:` des Blocks, hilfsweise das ID-Praefix,
erst zuletzt die Datei.
"""
f = feld(block, 'Ebene')
for eb in EBENEN:
if f.strip().upper().startswith(eb.upper()):
return eb, False
# IDs tragen haeufig ein Modulpraefix ("M003-StRS-01"). Die Ebene steht dann
# nicht am Anfang, sondern als Bestandteil der ID; ein reiner Praefixtest
# meldet sonst eine Fremdablage, die es nicht gibt.
kennung = aid.strip().upper()
for eb in EBENEN:
if re.search(r'(?:^|[^A-Z])%s' % eb.upper(), kennung):
return eb, eb.upper() != datei_ebene.upper()
return datei_ebene, False
def analysiere(lauf):
erg = os.path.join(lauf, 'Ergebnisse')
anf = []
for eb_datei in EBENEN:
for b in bloecke(os.path.join(erg, eb_datei + '.md')):
aid = feld(b, 'ID')
if not aid:
continue
eb, _ = ebene_von(b, aid, eb_datei)
fremd = not aid.strip().upper().startswith(eb_datei.upper())
belege = re.findall(r'\[(PRIM\w*R|SEKUND\w*R|KONTEXT)\]', b)
norm = []
for x in belege:
norm.append('PRIMÄR' if x.startswith('PRIM')
else 'SEKUNDÄR' if x.startswith('SEKUND') else 'KONTEXT')
status = feld(b, 'Status')
anf.append(dict(
id=aid, ebene=eb, datei_ebene=eb_datei, fremdabgelegt=fremd,
titel=feld(b, 'Titel'), typ=feld(b, 'Typ') or '(ohne)',
belege=norm, status=status,
hypothese=('HYPOTHESE' in status.upper()) or ('[HYPOTHESE]' in b),
workaround='workaround' in status.lower(),
tracelinks=feld(b, 'Tracelinks'),
konsolidierung=feld(b, 'Konsolidierung'),
pruefidee=feld(b, 'Prüfidee') or feld(b, 'Pruefidee'),
qm=feld(b, 'Qualitätsmerkmal'),
uebernahme=feld(b, 'Übernahmewürdigkeit') or feld(b, 'Uebernahmewuerdigkeit'),
))
return anf
def de(n):
return '{:,}'.format(int(n)).replace(',', '.')
def pct(a, b):
if not b:
return '–'
return ('%.1f' % (100.0 * a / b)).replace('.', ',') + ' %'
def abschnitt(anf):
n = len(anf)
if not n:
return '## Gefundene Anforderungen\n\nKeine Anforderungen im vorgegebenen Format gefunden.\n'
je_ebene = collections.Counter(a['ebene'] for a in anf)
typen = collections.Counter(a['typ'] for a in anf)
bel = collections.Counter()
for a in anf:
bel.update(a['belege'])
bel_ges = sum(bel.values())
ohne_beleg = [a for a in anf if not a['belege']]
hyp = [a for a in anf if a['hypothese']]
work = [a for a in anf if a['workaround']]
ohne_trace = [a for a in anf if not a['tracelinks'] or a['tracelinks'].lower() in ('-', '–', 'keine')]
ohne_pruef = [a for a in anf if not a['pruefidee']]
kons = [a for a in anf if a['konsolidierung'] and a['konsolidierung'].lower() != 'nein']
mit_qm = [a for a in anf if a['qm']]
# Uebernahmewuerdigkeit: erst ab Prompt-Fassung 2026-08-26; aeltere Laeufe kennen das Feld nicht
uebern = collections.Counter()
for a in anf:
v = a['uebernahme'].lower()
if not v:
continue
for schl in ('übernehmen', 'workaround', 'sonderfall', 'veraltet'):
if schl in v:
uebern[schl] += 1
break
else:
uebern['(sonstige Angabe)'] += 1
mit_uebern = sum(uebern.values())
risiko = [a for a in anf if RISIKO.search(a['typ'] + ' ' + a['titel'])]
risiko_ungedeckt = [a for a in risiko if 'PRIMÄR' not in a['belege'] and not a['hypothese']]
zahlen = sorted(len(a['belege']) for a in anf)
median = zahlen[n // 2] if n % 2 else (zahlen[n // 2 - 1] + zahlen[n // 2]) / 2.0
z = ['## Gefundene Anforderungen', '',
'Maschinell aus `Ergebnisse\\StRS.md`, `SyRS.md` und `SwRS.md` ausgewertet '
'(Blockformat des Prompts). Erzeugt von `analyse-anforderungen.py`.', '',
'Die Kenngrößen decken die **maschinell prüfbare** Hälfte des Evaluationsrahmens aus '
'Kapitel 4.3 ab: Belegqualität und Übernahmewürdigkeit gehören zur *Statement-Qualität*, '
'Verteilung und Konsolidierungskandidaten zur *Set-Qualität*, Tracelinks und '
'Belegklassifikation zur *Traceability-Qualität*. Die Expertenbewertung nach '
'Likert-Skala tritt daneben und wird hier nicht ersetzt.', '',
'### Verteilung über die Ebenen', '',
'| Ebene | Anzahl | Anteil |', '|---|---:|---:|']
for eb in EBENEN:
z.append('| %s | %d | %s |' % (eb, je_ebene.get(eb, 0), pct(je_ebene.get(eb, 0), n)))
z += ['| **Gesamt** | **%d** | 100 %% |' % n, '']
fremd = [a for a in anf if a.get('fremdabgelegt')]
if fremd:
nach = collections.Counter(
'%s-Block in `%s.md`' % (a['ebene'], a['datei_ebene']) for a in fremd)
z.append('')
z.append('> **Auffälligkeit – Ebene weicht von der Ablagedatei ab.** %d von %d '
'Anforderungen stehen in der Datei einer anderen Ebene: %s. Die Ebene wurde '
'aus dem Feld `Ebene:` beziehungsweise dem ID-Präfix bestimmt, nicht aus dem '
'Dateinamen. Für die Set-Qualität ist das relevant: Die Dreiteilung StRS / '
'SyRS / SwRS ist dann nicht mehr an der Dateistruktur ablesbar.'
% (len(fremd), n,
', '.join('%d × %s' % (v, k) for k, v in sorted(nach.items()))))
z.append('')
z += ['### Anforderungstypen', '', '| Typ | Anzahl | Anteil |', '|---|---:|---:|']
for t, c in typen.most_common(10):
z.append('| %s | %d | %s |' % (t, c, pct(c, n)))
if len(typen) > 10:
rest = sum(c for _, c in typen.most_common()[10:])
z.append('| (%d weitere) | %d | %s |' % (len(typen) - 10, rest, pct(rest, n)))
z.append('')
z += ['### Belegqualität', '', '| Messgröße | Wert |', '|---|---:|',
'| Belege gesamt | %s |' % de(bel_ges),
'| davon `PRIMÄR` | %s (%s) |' % (de(bel['PRIMÄR']), pct(bel['PRIMÄR'], bel_ges)),
'| davon `SEKUNDÄR` | %s (%s) |' % (de(bel['SEKUNDÄR']), pct(bel['SEKUNDÄR'], bel_ges)),
'| davon `KONTEXT` | %s (%s) |' % (de(bel['KONTEXT']), pct(bel['KONTEXT'], bel_ges)),
'| Belege je Anforderung (Median) | %s |' % str(median).replace('.', ','),
'| Anforderungen mit mindestens einem `PRIMÄR`-Beleg | %d (%s) |'
% (sum(1 for a in anf if 'PRIMÄR' in a['belege']),
pct(sum(1 for a in anf if 'PRIMÄR' in a['belege']), n)),
'']
z += ['### Übernahmewürdigkeit', '']
if not mit_uebern:
z += ['**nicht erhoben** – das Feld `Übernahmewürdigkeit` wurde erst mit der '
'Prompt-Fassung vom 2026-08-26 eingeführt und liegt für diesen Lauf nicht vor. '
'Hinweise auf Workarounds stecken ersatzweise im Feld `Status`.', '']
else:
z += ['| Einstufung | Anzahl | Anteil |', '|---|---:|---:|']
for k in ('übernehmen', 'workaround', 'sonderfall', 'veraltet', '(sonstige Angabe)'):
if uebern.get(k):
z.append('| %s | %d | %s |' % (k, uebern[k], pct(uebern[k], n)))
if n - mit_uebern:
z.append('| **ohne Angabe** | %d | %s |' % (n - mit_uebern, pct(n - mit_uebern, n)))
z.append('')
z += ['### Status', '', '| Kategorie | Anzahl | Anteil |', '|---|---:|---:|',
'| belegt | %d | %s |' % (n - len(hyp), pct(n - len(hyp), n)),
'| als `HYPOTHESE` gekennzeichnet | %d | %s |' % (len(hyp), pct(len(hyp), n)),
'| als Workaround vermerkt | %d | %s |' % (len(work), pct(len(work), n)),
'| Konsolidierungskandidaten | %d | %s |' % (len(kons), pct(len(kons), n)),
'| mit ISO-25010-Qualitätsmerkmal | %d | %s |' % (len(mit_qm), pct(len(mit_qm), n)),
'']
z += ['### Regelkonformität (Prüfung gegen die Vorgaben des Prompts)', '',
'| Vorgabe | Ergebnis |', '|---|---|']
z.append('| **Belegpflicht** – jede Anforderung mindestens ein Artefaktbeleg | %s |'
% ('**erfüllt** (0 Anforderungen ohne Beleg)' if not ohne_beleg
else '**verletzt** – %d ohne Beleg: %s' % (len(ohne_beleg),
', '.join(a['id'] for a in ohne_beleg[:8]) + (' …' if len(ohne_beleg) > 8 else ''))))
z.append('| **Risikobasierte Priorisierung** – Sicherheit, Abrechnung, Berechtigungen brauchen '
'einen `PRIMÄR`-Beleg oder die Kennzeichnung `[HYPOTHESE]` | %s |'
% ('**erfüllt** (%d risikorelevante Anforderungen, alle gedeckt)' % len(risiko)
if not risiko_ungedeckt
else '**verletzt** – %d von %d ungedeckt: %s' % (len(risiko_ungedeckt), len(risiko),
', '.join(a['id'] for a in risiko_ungedeckt[:8]) + (' …' if len(risiko_ungedeckt) > 8 else ''))))
z.append('| **Verifizierbarkeit** – jede Anforderung mit Prüfidee oder Akzeptanzkriterium | %s |'
% ('**erfüllt**' if not ohne_pruef
else '**verletzt** – %d ohne Prüfidee' % len(ohne_pruef)))
z.append('| **Übernahmewürdigkeit** – Einstufung für die Migrationsperspektive | %s |'
% ('*nicht erhoben* (Feld erst ab Prompt-Fassung 2026-08-26)' if not mit_uebern
else ('**erfüllt** (alle %d Anforderungen eingestuft)' % n if mit_uebern == n
else '**verletzt** – %d von %d ohne Angabe' % (n - mit_uebern, n))))
z.append('| **Traceability** – Verknüpfung zwischen den Ebenen | %d von %d mit Tracelinks (%s) |'
% (n - len(ohne_trace), n, pct(n - len(ohne_trace), n)))
z.append('')
return '\n'.join(z)
def main():
lauf = sys.argv[1]
anf = analysiere(lauf)
meta = os.path.join(lauf, '_meta')
os.makedirs(meta, exist_ok=True)
io.open(os.path.join(meta, 'anforderungen.json'), 'w', encoding='utf-8').write(
json.dumps(anf, indent=1, ensure_ascii=False))
text = abschnitt(anf)
io.open(os.path.join(meta, 'anforderungen.md'), 'w', encoding='utf-8').write(text + '\n')
if '--print' in sys.argv:
print(text)
else:
print('%s: %d Anforderungen ausgewertet -> _meta/anforderungen.md'
% (os.path.basename(os.path.normpath(lauf))[8:], len(anf)))
if __name__ == '__main__':
main()