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>
264 lines
12 KiB
Python
264 lines
12 KiB
Python
# -*- 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()
|