This commit is contained in:
team3
2026-06-23 16:23:12 +02:00
parent cd1c926599
commit cec8d1ee5f
8 changed files with 114 additions and 119 deletions

View File

@@ -20,7 +20,7 @@ from pathlib import Path
import database as db
from agents import kill_process, cancel_scope, clear_scope
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS
from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei
from lernen import FRAGETYPEN
@@ -46,10 +46,11 @@ RECHERCHE_BATCH = 20 # Crawl-Seiten je Batch
RECHERCHE_READERS = 2 # Reader-Agenten je Batch (Konsens ≥2 innerhalb des Batches)
RECHERCHE_THEMA_AGENTEN = 5 # Web-Modus (Quelle „thema", kein Crawl-Ordner)
RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent
SICHTUNG_CHUNK = 80 # Crawl-Seiten je Sichtungs-Chunk (Content/Noise-Triage)
SICHTUNG_READERS = 3 # Sichter je Chunk; Merge per Mehrheit, Tie-Break = Content
# Sichtung (Content/Noise) ist jetzt ein deterministischer Regel-Filter (config.CRAWL_*).
SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
log = logging.getLogger("creator.bausteine")
@@ -324,16 +325,17 @@ def active_bausteine() -> list[dict]:
def reset_bausteine(topic: str) -> None:
"""Löschen": räumt nur die generierten Bausteine weg (Dateien ab Inventar). BEHÄLT
Quelle (quelle.json), Crawl-Ordner und Sichtung. Voller Wipe inkl. Quelle/Crawl nur
über „Thema löschen" (DELETE /topics → rmtree(topic_dir))."""
_reset_ab_phase(topic, "Inventar")
"""Entfernen": löscht den GESAMTEN Bausteine-Bereich — Crawl, Sichtung, Inventar … Fragen.
BEHÄLT nur die Themen-Config `quelle.json` (Typ/Link/Spec). Re-Generieren crawlt neu.
(Crawl/Sichtung gehören zu den Bausteinen; nur die Config ist „Thema".)"""
files = _bausteine_files(topic)
files["final"].unlink(missing_ok=True)
files["sidecar"].unlink(missing_ok=True)
files["frage_muster"].unlink(missing_ok=True)
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # Crawl gehört zu Bausteinen
shutil.rmtree(files["arbeit"], ignore_errors=True)
_bausteine_errors.pop(topic, None)
async def reset_bausteine_db(topic: str) -> None:
"""DB-Pendant zu reset_bausteine: Inventar…Fragen aus der DB; Coverage/Sichtung + Quelle bleiben."""
await _reset_db_ab_phase(topic, "Inventar")
# quelle.json bleibt bewusst stehen — das ist die Themen-Config.
def _phase_idx(label: str) -> int:
@@ -550,6 +552,21 @@ def _n_chunks(count: int, size: int = SUBBAUSTEIN_CHUNK) -> int:
return min(SUBBAUSTEIN_MAX, max(1, math.ceil(count / size)))
def _lpt_chunks(gewichte: list[int], target: int) -> list[list[int]]:
"""Indizes lastbalanciert auf Chunks verteilen (LPT, Makespan-minimal). Gewicht = Kosten je Index.
K = ceil(Gesamtgewicht/target); schwerste zuerst in den jeweils leichtesten Bin. → Index-Listen."""
if not gewichte:
return []
K = max(1, math.ceil(sum(gewichte) / max(1, target)))
bins: list[list[int]] = [[] for _ in range(K)]
last = [0] * K
for i in sorted(range(len(gewichte)), key=lambda x: gewichte[x], reverse=True):
j = min(range(K), key=lambda b: last[b])
bins[j].append(i)
last[j] += gewichte[i]
return [b for b in bins if b]
def _merge_finder(num: int, idx: dict, finder: list[dict]) -> tuple[list[str], list[str]]:
"""Subbausteine eines Bausteins über die Finder mergen → (konsens ≥2, rest ==1)."""
counts: dict[str, int] = {}
@@ -753,7 +770,18 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
items = [(titel, sub) for titel, subs in roh.items() for sub in subs] # globale id = index+1
if not items:
return {titel: [] for titel in roh}
chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), STUFE_CHUNK))
# Chunks aus GANZEN Bausteinen packen (keinen Baustein splitten) → Rater sieht je Baustein
# alle Subs und kann relativ einstufen. Item-Indizes je Baustein in roh-Reihenfolge.
chunks, cur, i = [], [], 0
for _titel_b, subs in roh.items():
g = list(range(i, i + len(subs)))
i += len(subs)
if cur and len(cur) + len(g) > STUFE_CHUNK:
chunks.append(cur)
cur = []
cur.extend(g)
if cur:
chunks.append(cur)
n = len(chunks)
def rater_paths(c):
@@ -769,7 +797,14 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
vorhanden = sum(1 for p in paths if _stufen_schema(_json_datei(p), local_set))
if vorhanden >= 2:
return True
enum = "\n".join(f"{k}. [{items[j][0]}] {items[j][1]}" for k, j in enumerate(item_idxs, 1))
enum_zeilen, cur_b = [], None
for k, j in enumerate(item_idxs, 1):
b, sub = items[j]
if b != cur_b:
enum_zeilen.append(f"\nBAUSTEIN: {b}")
cur_b = b
enum_zeilen.append(f"{k}. {sub}")
enum = "\n".join(enum_zeilen).strip()
offen = [(i, p) for i, p in enumerate(paths, 1) if not _stufen_schema(_json_datei(p), local_set)]
slots = [{
"key": f"bausteine-{topic}-stufe-c{c}-{i}",
@@ -1002,7 +1037,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
if not bausteine:
return {}
typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items())
chunks = _chunk_nums(list(range(len(bausteine))), _n_chunks(len(bausteine))) # ~10 Bausteine/Chunk
chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl
def roh_path(ci):
return arbeit / f"frage-muster-c{ci}.json"
@@ -1223,65 +1258,25 @@ async def _set_inventar(topic: str, eintrag: str, status: str) -> None:
await db.set_baustein_status(topic, norm, status)
def _sichtung_schema(data) -> list[str] | None:
"""{"noise": ["datei.txt", …]} → Liste Dateinamen (leer erlaubt) · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("noise"), list):
return None
return [s for x in data["noise"] if (s := str(x).strip())]
async def _sichte_inhalt(ctx: GenContext, set_p, files: dict, ordner, pages: list[str]) -> set[str]:
"""Content/Noise-Triage nach dem Crawl. Je Chunk SICHTUNG_READERS Sichter; Merge je Seite
per Mehrheit, Tie-Break = Content (konservativ). Fehler/Ausfall → Content (fail-open).
→ Menge der Content-Dateinamen (nie leer, solange pages nicht leer)."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
pages = list(pages)
if not pages:
return set()
arbeit = files["arbeit"]
def _sichte_regeln(ordner, pages: list[str]) -> tuple[list[str], list[str]]:
"""Deterministischer Content/Noise-Filter (config.CRAWL_*). Substring-Match (klein) gegen
URL + Dateiname. Reihenfolge: keep > noise > min_chars > behalten. → (content, noise)."""
ordner = Path(ordner)
def _snippet(fn: str) -> str:
content, noise = [], []
for fn in pages:
zeilen = _read(ordner / fn).splitlines()
url = zeilen[0][len("QUELLE:"):].strip() if zeilen and zeilen[0].startswith("QUELLE:") else ""
body = " ".join(z.strip() for z in zeilen[1:] if z.strip())[:200]
return f"- {fn} · {url} · {body}"
chunks = _chunk_nums(sorted(pages), max(1, math.ceil(len(pages) / SICHTUNG_CHUNK)))
async def _chunk(ci: int, chunk: list[str]) -> set[str]:
seiten = "\n".join(_snippet(fn) for fn in chunk)
paths = [arbeit / f"sichtung-c{ci}-{i}.json" for i in range(1, SICHTUNG_READERS + 1)]
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": f"bausteine-{topic}-sichtung-c{ci}-{i}",
"prompt": _prompt("Bausteine-Seiten-Sichtung", topic=topic, seiten=seiten, out_path=p),
"role": "judge", "capabilities": "files",
"payload": (lambda result, p=p: _sichtung_schema(_json_datei(p))),
} for i, p in enumerate(paths, 1)]
ergebnisse = await _race(topic, f"Seiten-Sichtung {ci}", slots, 1,
_timeout("recherche_mapping", len(chunk)), provider,
cancelled=is_cancelled, grace=KONSENS_GRACE)
cs = set(chunk)
stimmen = [set(r) & cs for r in (ergebnisse or [])] # Noise-Menge je Reader (nur Chunk-Seiten)
R = len(stimmen)
if not R:
return set() # fail-open: kein Urteil → alles Content
# Seite = Noise nur bei strenger Mehrheit; Gleichstand/Minderheit → Content.
return {page for page in chunk if 2 * sum(page in s for s in stimmen) > R}
async def melde(d, t):
set_p(f"Sichte Seiten {d}/{t}")
set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten"))
teile = await _gather_fortschritt([_chunk(ci, c) for ci, c in enumerate(chunks, 1)], len(chunks), melde)
if is_cancelled():
return set(pages)
noise = set().union(*(t for t in teile if isinstance(t, set))) if teile else set()
content = set(pages) - noise
_log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)} ({len(chunks)} Chunks × {SICHTUNG_READERS} Sichter)")
return content or set(pages) # alles Noise? → fail-open, alle behalten
body = "\n".join(zeilen[1:]).strip()
hay = f"{url}\n{fn}".lower()
if any(p in hay for p in CRAWL_KEEP_PATTERNS):
content.append(fn)
elif any(p in hay for p in CRAWL_NOISE_PATTERNS):
noise.append(fn)
elif len(body) < CRAWL_MIN_CHARS:
noise.append(fn)
else:
content.append(fn) # Default: behalten — alles mit Inhalt bleibt
return content, noise
async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordner) -> bool:
@@ -1307,12 +1302,11 @@ async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordn
await asyncio.to_thread(_pdfs_konvertieren, ordner)
pages = sorted(set(_crawl_index(ordner).values()))
if pages:
set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten"))
await db.delete_coverage(topic)
content = await _sichte_inhalt(ctx, set_p, files, ordner, pages)
if is_cancelled():
return False
noise = [p for p in pages if p not in content]
await db.mark_inhalt(topic, sorted(content), noise)
content, noise = _sichte_regeln(ordner, pages) # deterministischer Regel-Filter
await db.mark_inhalt(topic, sorted(content), sorted(noise))
_log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)} (Regeln)")
await db.set_step_status(topic, "Quelle aufbereiten", "fertig")
return True

View File

@@ -42,6 +42,16 @@ RECHERCHE_GRACE = 900
# Prüf-Loops lassen Rest-Beanstandungen danach stehen.
KONSENS_MAX_RUNDEN = 3
# Crawler-Sichtung (Content/Noise) — deterministischer Regel-Filter statt LLM.
# Match: Substring (klein) gegen URL UND Dateiname. Reihenfolge: keep > noise > min_chars > behalten.
# Sonderregeln einfach hier ergänzen.
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # immer Content
CRAWL_NOISE_PATTERNS = [ # eindeutig themenfremd → raus
"clubs", "events", "podcasts", "resources", "-u-",
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
]
CRAWL_MIN_CHARS = 400 # zu wenig Text → raus
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
TIMEOUTS = {

View File

@@ -864,8 +864,9 @@ async def delete_guide_content(topic: str, format: str | None = None) -> None:
async def delete_topic_pipeline(topic: str) -> None:
"""Alle Pipeline-Inhalte eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State/Quelle)."""
"""Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State).
NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle)."""
db = await get_db()
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state", "quelle"):
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit()

View File

@@ -15,9 +15,9 @@ from database import (
create_element, list_elements, get_element, update_element, delete_element,
list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
delete_topic_pipeline, get_guide_content, delete_guide_content,
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, reset_bausteine_db, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen, _zufall_malus
from guide import generate_guide, guide_slot_dateien, guide_fertig_step
@@ -88,6 +88,7 @@ async def remove_topic(topic: str):
await delete_topic(topic)
await delete_baustein_daten(topic)
await delete_topic_pipeline(topic)
await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg
await delete_guide_content(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
return {"ok": True}
@@ -172,8 +173,8 @@ async def cancel_bausteine_route(topic: str):
@router.delete("/bausteine")
async def remove_bausteine(topic: str):
reset_bausteine(topic) # Dateien ab Inventar — Quelle/Crawl bleiben
await reset_bausteine_db(topic) # DB ab Inventar — Coverage/Sichtung + Quelle bleiben
reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt
await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt
return {"ok": True}

View File

@@ -1,18 +0,0 @@
Du sortierst gecrawlte Seiten zum Lern-Thema "{topic}". Markiere NUR die Seiten, die eindeutig KEINEN fachlichen Lerninhalt tragen.
SEITEN (Dateiname · URL · Auszug):
{seiten}
Regeln:
- `noise` = eindeutig kein FACHLICHER Lerninhalt zum Thema. Markiere insbesondere:
- Veranstaltungs-/Termin-, Mitglieder-/Profil-, Vereins-/Community-, Podcast-/Video-Verzeichnisse, Login/Impressum/Karriere.
- **Marketing/Vertrieb:** Positionierung, ICP/Zielgruppe, Editionen/Preise/Pläne, Plattform-/Produkt-Werbung ohne How-to.
- **Meta/Navigation:** Übersichts-/Index-/Landing-Seiten ohne eigenen Stoff, Lernpfad-/Kurs-Intros, Voraussetzungs-/„Was du lernst"-Seiten.
- **Fremde Tracks:** Seiten eines ANDEREN Lern-Pfads/Tracks oder Levels als dieses Thema (z.B. Frontend statt Backend, „Intermediate"/„Advanced" wenn das Thema „Essentials" ist).
- Im ZWEIFEL bei ECHTER Technik NICHT markieren: konkrete Konzepte, Anleitungen, Code, Konfiguration, Verfahren bleiben Content — auch Deployment/PaaS/Betrieb.
- Nutze NUR die exakten Dateinamen aus der Liste oben.
Schreibe NUR die JSON-Datei nach {out_path} (kein weiterer Text):
{{"noise": ["exakter-dateiname.txt", "…"]}}
Ist nichts eindeutig themenfremd, schreibe {{"noise": []}}.

View File

@@ -3,13 +3,13 @@ Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Schwierigkeit e
STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen):
{strittig}
Stufen:
- **einfach**: Grundlegend, Anfänger versteht es sofort, Kernfunktion.
- **mittel**: Braucht etwas Erklärung, gängige Variante oder wichtiges Detail.
- **schwer**: Sonderfall, Tücke, fortgeschritten, leicht falsch zu machen.
Stufe = Lern-/Verständnisaufwand (nicht Seltenheit/Obskurität):
- **einfach**: Basis-Fakt/Definition, sofort verständlich. Auch Versions-Details, Pfade, Konstanten, einzelne Fakten.
- **mittel**: braucht Erklärung — mehrere Teile, Zusammenhang, typische Anwendung.
- **schwer**: komplexes Zusammenspiel mehrerer Konzepte, tieferes Verständnis nötig. NICHT „schwer" nur weil selten/neu/Edge-Case.
Regeln:
- Wäge die Stimmen ab, entscheide fachlich. Im Zweifel die niedrigere Stufe.
- Wäge die Stimmen ab, entscheide fachlich nach Lernaufwand. Im Zweifel die niedrigere Stufe.
- Für JEDE strittige Nummer genau eine Stufe.
Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -1,16 +1,23 @@
Stufe jeden Subbaustein des Themas "{topic}" nach Schwierigkeit ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles).
Stufe jeden Subbaustein des Themas "{topic}" nach **Lern-/Verständnisaufwand** ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles).
SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein):
SUBBAUSTEINE (nach Baustein gruppiert):
{subbausteine}
Stufen:
- **einfach**: Grundlegend. Ein Anfänger versteht es sofort. Die Kernfunktion, ohne die der Baustein nicht nutzbar ist.
- **mittel**: Braucht etwas Erklärung. Eine gängige Variante, ein wichtiges Detail, ein zweiter Schritt.
- **schwer**: Sonderfall, Tücke, fortgeschritten. Leicht falsch zu machen, selten gebraucht, oder setzt mehr Verständnis voraus.
Was die Stufe misst — wie viel Aufwand, den Punkt zu VERSTEHEN und ANZUWENDEN:
- **einfach**: Basis-Fakt oder Definition. Sofort verständlich. Der Einstieg in den Baustein.
- **mittel**: braucht etwas Erklärung. Mehrere Teile, ein Zusammenhang, eine typische Anwendung.
- **schwer**: komplexes Zusammenspiel mehrerer Konzepte. Setzt tieferes Verständnis voraus, ist gedanklich anspruchsvoll.
WICHTIG — was NICHT die Stufe bestimmt:
- Seltenheit ist KEINE Schwierigkeit. Ein Nischen-Feature kann trotzdem einfach sein.
- Versions-Details (z.B. „seit 6.4.12.0"), Konstanten, Pfade, einzelne Fakten → meist **einfach**, nicht schwer.
- Edge-Cases/Gotchas, die nur eine simple Regel sind (z.B. „Name muss dem Ordner entsprechen") → **einfach/mittel**, nicht schwer.
- „schwer" NUR, wenn der Punkt selbst gedanklich schwer ist — nicht weil er obskur oder neu ist.
Regeln:
- Stufe jeden Punkt einzeln, gemessen an SEINEM Baustein. Im Zweifel die niedrigere Stufe.
- Jeder Baustein sollte mindestens einen „einfach"-Punkt haben (den Einstieg).
- Stufe RELATIV im jeweiligen Baustein: vergleiche die Punkte eines Bausteins miteinander.
- Die meisten Punkte sind einfach/mittel. „schwer" nur für die echten Brocken.
- Jeder Baustein hat mindestens einen „einfach"-Punkt (den Einstieg). Im Zweifel die niedrigere Stufe.
- Urteile nur — erfinde nichts dazu, ändere keine Subbausteine.
Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -1,13 +1,13 @@
Mehrere Agenten haben unabhängig die Subbausteine je Baustein des Themas "{topic}" ermittelt. Pro Baustein ist der KONSENS (von mindestens zwei Agenten genannt) und der strittige REST (nur einmal genannt) gelistet. Entscheide je Baustein die finale Subbaustein-Liste.
Unten je Baustein des Themas "{topic}" die gefundenen Subbausteine. Gib je Baustein die **bereinigte** finale Liste zurück.
{bausteine}
Regeln:
- Übernimm alle Konsens-Subbausteine.
- Einen strittigen Rest-Punkt AUFNEHMEN, wenn er ein eigener, korrekter Teilpunkt des Bausteins ist, den der Konsens nicht abdeckt.
- VERWERFEN, wenn er eine Dublette/Variante eines vorhandenen Punkts ist, zu fein granular, am Rand des Themas oder fachlich zweifelhaft.
- Die Anzahl folgt der Schwierigkeit: trivialer Baustein wenige Punkte, komplexer viele. Blähe nichts auf, lass nichts Wesentliches weg.
- Subbausteine wörtlich übernehmen, nicht umformulieren. Jeder Punkt atomar (eine Aussage).
- **Dubletten zusammenführen:** Subbausteine, die denselben Punkt mit anderen Worten sagen, sind EINER. Behalte den klarsten, streiche die Umformulierungen. (z.B. „Broadcast Mode verfügbar" und „Broadcast-Modus aktivieren" → einer.)
- Behalte alle fachlich **DISTINKTEN** Teilpunkte vollständig — nichts Wesentliches weglassen.
- Verwirf, was zu fein granular, am Rand des Themas oder fachlich zweifelhaft ist.
- Jeder Punkt atomar (eine Aussage). Behaltene Punkte WÖRTLICH übernehmen, nicht umformulieren, nichts erfinden.
- Die Anzahl folgt der Schwierigkeit: lieber wenige distinkte als viele redundante Punkte.
Schreibe NUR die Datei {out_path} — pro Baustein ein baustein-Marker (Titel EXAKT wie oben), darunter die finale Subbaustein-Liste: