Skill 13.0.0/13.1.0, Adapter 2.5.2. Spiegel-Arbeitsverzeichnis: OpenCode laeuft nicht mehr direkt im Codebasis-Root, sondern in einem Verzeichnis aus Junctions auf die Top-Level-Eintraege, mit einem echten Ergebnisverzeichnis, dessen Inhalt nach dem Lauf uebernommen wird. Damit landen relative wie absolute Ausgabepfade am richtigen Ort, ohne dass der Prompt vom Wortlaut der Claude-Laeufe abweichen muss. Der Spiegel liegt in _meta des Laufs; die Codebasis bleibt unberuehrt. Der Spiegel allein genuegte nicht. Sechs Laeufe schrieben mit korrektem absolutem Pfad und wurden dennoch abgewiesen, weil OpenCode Ziele im Repository gegen den worktree-relativen Pfad abgleicht - ein Befund, der seit Skill 10.0.2 dokumentiert war und erst sichtbar wurde, als der Spiegel vom Temp-Verzeichnis ins Projekt wanderte. analyse-anforderungen.py erkennt Kennungen als Markdown-Ueberschrift, auch ohne Feldnamen, sofern die Pflichtfelder folgen. Regressionsprobe an fuenf Claude-Laeufen unveraendert. Ergebnisse der gueltigen Laeufe: Qwen 3.5-9B liefert 107 der 119 Anforderungen, davon 75 im Modus custom mit nur drei Subagenten. Gemma kommt auf 12. Der Modus builtin fiel bei beiden Modellen aus - drei von drei Laeufen endeten nach einem Turn ohne einen Werkzeugaufruf. Die 13 Laeufe mit Adapter 2.3.0 bis 2.5.1 sind Artefakte der Fehlersuche und als adapterbedingte Fehlmessungen gekennzeichnet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
291 lines
14 KiB
Python
291 lines
14 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]*')
|
||
|
||
|
||
# Ueberschriftenmarker vor einem Feldnamen, z. B. "### ID: StRS-1".
|
||
# Beobachtet am 01.09.2026 im Lauf Iteration 15/.../v13.0.0-312f: sechs Dateien
|
||
# mit vollstaendig ausgefuellten, regelkonformen Bloecken wurden mit 0 gezaehlt,
|
||
# weil die ID-Zeile als Markdown-Ueberschrift gesetzt war.
|
||
UEBERSCHRIFT_FELD = re.compile(r'(?m)^[ \t]*#{1,6}[ \t]+(?=(?:\*\*)?[A-Za-zÄÖÜäöüß ]{1,40}:)')
|
||
|
||
|
||
# Kennung als blosse Ueberschrift, ohne Feldnamen: "### StRS-001".
|
||
# Nur dann als ID gewertet, wenn die Pflichtfelder unmittelbar folgen - sonst
|
||
# wuerde jede Zwischenueberschrift zur Anforderung. Beobachtet am 01.09.2026 im
|
||
# Lauf Iteration 15/.../v13.0.0-0b06 (qwen, solo): sieben Dateien, alle Felder
|
||
# ausser der Kennung regelkonform beschriftet.
|
||
UEBERSCHRIFT_KENNUNG = re.compile(
|
||
r'(?m)^[ \t]*#{1,6}[ \t]*((?:StRS|SyRS|SwRS)[A-Za-z0-9_.\-]*)[ \t]*$'
|
||
r'(?=(?:[ \t]*\n)*(?:[ \t]*(?:\*\*)?(?:Titel|Ebene)[:\*]))',
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def normalisiere(text):
|
||
"""Markdown-Auszeichnung der Feldnamen auf die Klartextform zuruecknehmen.
|
||
|
||
Modelle setzen die Feldvorgabe des Prompts haeufig als Markdown - fett
|
||
(``**ID:**``), als Ueberschrift (``### ID:``) oder ganz ohne Feldnamen
|
||
(``### StRS-001``). Inhaltlich ist der Block dann regelkonform; ohne
|
||
Normalisierung bleibt er unerkannt.
|
||
"""
|
||
text = UEBERSCHRIFT_KENNUNG.sub(lambda m: 'ID: ' + m.group(1), text)
|
||
text = UEBERSCHRIFT_FELD.sub('', text)
|
||
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()
|