247 lines
12 KiB
Python
247 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)
|
||
|
||
|
||
def bloecke(pfad):
|
||
"""Zerlegt eine Anforderungsdatei in Bloecke ab jeder ID:-Zeile."""
|
||
if not os.path.exists(pfad):
|
||
return
|
||
text = 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
|
||
for eb in EBENEN:
|
||
if aid.strip().upper().startswith(eb.upper()):
|
||
return eb, aid.strip().upper()[:4] != datei_ebene.upper()[:4]
|
||
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()
|