update
This commit is contained in:
@@ -24,9 +24,10 @@ E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit
|
|||||||
E2 Artefakte je Soll-Punkt-Gruppe: Flashcards + 1 typisiertes Beispiel pro Atom
|
E2 Artefakte je Soll-Punkt-Gruppe: Flashcards + 1 typisiertes Beispiel pro Atom
|
||||||
(Form: text|mathe|code|tabelle, beste Passung) → Form-Gate
|
(Form: text|mathe|code|tabelle, beste Passung) → Form-Gate
|
||||||
(Syntax/Struktur parse-only, kein Ausführen) → Verify-Panel gegen Anker → Fix
|
(Syntax/Struktur parse-only, kein Ausführen) → Verify-Panel gegen Anker → Fix
|
||||||
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen
|
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check
|
||||||
→ Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
|
→ Bausteine = Ziel-Gruppen im Band (split/merge)
|
||||||
→ topologische Ordnung (Bausteine + Atome darin)
|
→ Ordnung per Judge je Level (Quellpositions-Median als Prior,
|
||||||
|
Permutations-Gate, Fallback = Quellreihenfolge mit Befund)
|
||||||
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker; Ausgabe DELIMITED, nicht JSON
|
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker; Ausgabe DELIMITED, nicht JSON
|
||||||
— LaTeX-Backslashes) → det. Checks → Prüfer → Fix (Fix-Cap gegen Churn).
|
— LaTeX-Backslashes) → det. Checks → Prüfer → Fix (Fix-Cap gegen Churn).
|
||||||
Beispiel-Marker setzt der Code DETERMINISTISCH (nicht der Writer);
|
Beispiel-Marker setzt der Code DETERMINISTISCH (nicht der Writer);
|
||||||
@@ -45,7 +46,7 @@ Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene.
|
|||||||
- `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])`
|
- `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])`
|
||||||
- `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)`
|
- `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)`
|
||||||
- `anker(atom_id, quelle_id, start, ende, zitat)`
|
- `anker(atom_id, quelle_id, start, ende, zitat)`
|
||||||
- `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)`
|
- `kanten(topic, von_atom, zu_atom, art verwandt, status)` — Dedup-Gedächtnis
|
||||||
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
|
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
|
||||||
— beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle);
|
— beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle);
|
||||||
code/tabelle kommen verbatim via `<!-- beispiel: id -->` in den Guide (Assembly-Swap)
|
code/tabelle kommen verbatim via `<!-- beispiel: id -->` in den Guide (Assembly-Swap)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ _api_sem = asyncio.Semaphore(MAX_CONCURRENT_API_AGENTS)
|
|||||||
_active: dict[str, float] = {} # key → Startzeit (Anzeige)
|
_active: dict[str, float] = {} # key → Startzeit (Anzeige)
|
||||||
_prozesse: dict[str, asyncio.subprocess.Process] = {}
|
_prozesse: dict[str, asyncio.subprocess.Process] = {}
|
||||||
_abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe
|
_abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe
|
||||||
|
_pausiert: set[str] = set() # Key-Präfixe sanft pausierter Läufe
|
||||||
|
|
||||||
# ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls
|
# ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls
|
||||||
# kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE:
|
# kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE:
|
||||||
@@ -108,14 +109,25 @@ def abbrechen(prefix: str) -> None:
|
|||||||
_kill(p)
|
_kill(p)
|
||||||
|
|
||||||
|
|
||||||
|
def pausieren(prefix: str) -> None:
|
||||||
|
"""Sanfte Pause: Wartende + Neue liefern sofort „pausiert", Laufende laufen
|
||||||
|
aus und ihre Ergebnisse landen noch in der DB (Gegenstück zu abbrechen)."""
|
||||||
|
_pausiert.add(prefix)
|
||||||
|
|
||||||
|
|
||||||
def abbruch_aufheben(prefix: str) -> None:
|
def abbruch_aufheben(prefix: str) -> None:
|
||||||
_abgebrochen.discard(prefix)
|
_abgebrochen.discard(prefix)
|
||||||
|
_pausiert.discard(prefix)
|
||||||
|
|
||||||
|
|
||||||
def _ist_abgebrochen(key: str) -> bool:
|
def _ist_abgebrochen(key: str) -> bool:
|
||||||
return any(key.startswith(p) for p in _abgebrochen)
|
return any(key.startswith(p) for p in _abgebrochen)
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_pausiert(key: str) -> bool:
|
||||||
|
return any(key.startswith(p) for p in _pausiert)
|
||||||
|
|
||||||
|
|
||||||
def provider_verfuegbar(provider: str) -> bool:
|
def provider_verfuegbar(provider: str) -> bool:
|
||||||
cfg = PROVIDERS.get(provider)
|
cfg = PROVIDERS.get(provider)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
@@ -136,6 +148,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
|||||||
role: str = "quick", capabilities: str = "none") -> AgentErgebnis:
|
role: str = "quick", capabilities: str = "none") -> AgentErgebnis:
|
||||||
"""Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web).
|
"""Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web).
|
||||||
Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py)."""
|
Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py)."""
|
||||||
|
if _ist_pausiert(key): # VOR dem Fake-Shortcut — sonst ist Pause untestbar
|
||||||
|
return AgentErgebnis(1, "", "pausiert")
|
||||||
if os.getenv("CREATOR_FAKE_AGENTS"):
|
if os.getenv("CREATOR_FAKE_AGENTS"):
|
||||||
import fake_agents
|
import fake_agents
|
||||||
return await fake_agents.antwort(key, prompt, capabilities)
|
return await fake_agents.antwort(key, prompt, capabilities)
|
||||||
@@ -154,6 +168,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
|||||||
global _api_inflight
|
global _api_inflight
|
||||||
warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms)
|
warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms)
|
||||||
async with sem:
|
async with sem:
|
||||||
|
if _ist_pausiert(key): # wartete im Semaphor, als die Pause kam
|
||||||
|
return AgentErgebnis(1, "", "pausiert")
|
||||||
if _ist_abgebrochen(key):
|
if _ist_abgebrochen(key):
|
||||||
return AgentErgebnis(1, "", "abgebrochen")
|
return AgentErgebnis(1, "", "abgebrochen")
|
||||||
_active[key] = time.time()
|
_active[key] = time.time()
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ SOLL_PUNKTE_PER_SQRT = 1.0
|
|||||||
SOLL_PUNKTE_MIN = 5
|
SOLL_PUNKTE_MIN = 5
|
||||||
|
|
||||||
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
|
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
|
||||||
ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard
|
ABSCHNITT_CHARS = 6_000 # lost-in-the-middle-Guard; 12k riss bei dichten
|
||||||
|
# tex-Quellen das 32k-Output-Cap (aak: ø 11.7k, Spitzen
|
||||||
|
# 32k → 5-min-Calls, Hedge-Zwillinge, Halbier-Kaskade)
|
||||||
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
|
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
|
||||||
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
|
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
|
||||||
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
|
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
|
||||||
@@ -84,9 +86,6 @@ MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmi
|
|||||||
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
|
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
|
||||||
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
|
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
|
||||||
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
|
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
|
||||||
# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus,
|
|
||||||
# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel).
|
|
||||||
BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34)
|
|
||||||
|
|
||||||
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
|
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
|
||||||
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen
|
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # Worked-Example-Formen
|
||||||
@@ -131,7 +130,6 @@ TIMEOUTS = {
|
|||||||
"soll_konsens": (300, 2),
|
"soll_konsens": (300, 2),
|
||||||
"extraktion": (450, 0),
|
"extraktion": (450, 0),
|
||||||
"merge": (150, 8),
|
"merge": (150, 8),
|
||||||
"braucht": (200, 0),
|
|
||||||
"soll_zuordnung": (300, 8),
|
"soll_zuordnung": (300, 8),
|
||||||
"luecke": (450, 0),
|
"luecke": (450, 0),
|
||||||
"artefakt": (450, 20),
|
"artefakt": (450, 20),
|
||||||
@@ -142,7 +140,7 @@ TIMEOUTS = {
|
|||||||
"ziele": (300, 6),
|
"ziele": (300, 6),
|
||||||
"level": (200, 4),
|
"level": (200, 4),
|
||||||
"kapitel": (200, 2),
|
"kapitel": (200, 2),
|
||||||
"zyklus": (150, 0),
|
"ordnung": (300, 2),
|
||||||
"writer": (450, 60),
|
"writer": (450, 60),
|
||||||
"pruefer": (600, 5),
|
"pruefer": (600, 5),
|
||||||
"pruefer_urteil": (300, 5),
|
"pruefer_urteil": (300, 5),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS atome(
|
|||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
||||||
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
|
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
|
||||||
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
|
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
|
||||||
ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]');
|
ord INTEGER DEFAULT 0, merged_into INTEGER);
|
||||||
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
|
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
|
||||||
CREATE TABLE IF NOT EXISTS anker(
|
CREATE TABLE IF NOT EXISTS anker(
|
||||||
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
|
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
|
||||||
@@ -93,7 +93,7 @@ CREATE TABLE IF NOT EXISTS lernziele(
|
|||||||
CREATE TABLE IF NOT EXISTS bausteine(
|
CREATE TABLE IF NOT EXISTS bausteine(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
||||||
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
|
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
|
||||||
level TEXT NOT NULL DEFAULT 'M');
|
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge');
|
||||||
CREATE TABLE IF NOT EXISTS kapitel(
|
CREATE TABLE IF NOT EXISTS kapitel(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
||||||
@@ -143,11 +143,16 @@ def _init_schema(con: sqlite3.Connection) -> None:
|
|||||||
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
||||||
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'",
|
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'",
|
||||||
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
|
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
|
||||||
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"):
|
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'",
|
||||||
|
# braucht-Maschinerie entfernt: Ordnung kommt vom Judge,
|
||||||
|
# nicht mehr vom (zu dünnen) Abhängigkeits-Graph
|
||||||
|
"ALTER TABLE atome DROP COLUMN braucht"):
|
||||||
try:
|
try:
|
||||||
con.execute(zusatz)
|
con.execute(zusatz)
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass # Spalte existiert schon
|
pass # Spalte existiert schon (bzw. ist schon weg)
|
||||||
|
con.execute("DELETE FROM kanten WHERE art='braucht'") # tote Daten, kein Leser mehr
|
||||||
# Status-Marker entfernter Ebenen (diagramme/fehlersuche) zurückrollen — sonst
|
# Status-Marker entfernter Ebenen (diagramme/fehlersuche) zurückrollen — sonst
|
||||||
# kennt _ORDNUNG den Status nicht und der Resume fiele fälschlich auf korpus zurück.
|
# kennt _ORDNUNG den Status nicht und der Resume fiele fälschlich auf korpus zurück.
|
||||||
con.execute("UPDATE topics SET status='struktur_fertig'"
|
con.execute("UPDATE topics SET status='struktur_fertig'"
|
||||||
|
|||||||
@@ -16,13 +16,11 @@ FAKE_TEXT = f"{SATZ_DFA}\n\n{SATZ_POTENZ}\n\n{SATZ_PUMPING}\n"
|
|||||||
|
|
||||||
ATOME = [
|
ATOME = [
|
||||||
{"titel": "Deterministischer endlicher Automat", "typ": "begriff",
|
{"titel": "Deterministischer endlicher Automat", "typ": "begriff",
|
||||||
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E", "braucht": []},
|
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E"},
|
||||||
{"titel": "Potenzmengenkonstruktion", "typ": "verfahren",
|
{"titel": "Potenzmengenkonstruktion", "typ": "verfahren",
|
||||||
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M",
|
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M"},
|
||||||
"braucht": ["Deterministischer endlicher Automat"]},
|
|
||||||
{"titel": "Pumping-Lemma", "typ": "aussage",
|
{"titel": "Pumping-Lemma", "typ": "aussage",
|
||||||
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S",
|
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S"},
|
||||||
"braucht": ["Deterministischer endlicher Automat"]},
|
|
||||||
]
|
]
|
||||||
PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)),
|
PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)),
|
||||||
("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))]
|
("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))]
|
||||||
@@ -218,31 +216,10 @@ def _kapitel_schnitt(prompt: str):
|
|||||||
for n, g in enumerate(dict.fromkeys(grenzen))]
|
for n, g in enumerate(dict.fromkeys(grenzen))]
|
||||||
|
|
||||||
|
|
||||||
def _zyklus(prompt: str):
|
def _baustein_ordnung(prompt: str):
|
||||||
m = re.search(r"^(\d+)→(\d+):", _text_nach(prompt, "ZYKLUS:"), re.MULTILINE)
|
nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE"),
|
||||||
return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {}
|
re.MULTILINE)]
|
||||||
|
return nummern # Identität: Prior-Ordnung ist im Fake die Lehr-Reihenfolge
|
||||||
|
|
||||||
def _braucht_aufloesung(prompt: str):
|
|
||||||
# je ANGABE den Kandidaten mit den meisten geteilten Tokens (Substring, ≥4 Zeichen)
|
|
||||||
# wählen, sonst null — spiegelt den Substring-Prefilter der Auflösung.
|
|
||||||
def toks(s):
|
|
||||||
return {t for t in re.findall(r"\w+", s.lower()) if len(t) >= 4}
|
|
||||||
out = []
|
|
||||||
bloecke = re.split(r"ANGABE (\d+):", prompt)[1:]
|
|
||||||
for i in range(0, len(bloecke), 2):
|
|
||||||
n, body = int(bloecke[i]), bloecke[i + 1]
|
|
||||||
mp = re.search(r"Voraussetzung „([^“]+)", body)
|
|
||||||
ph = mp.group(1) if mp else ""
|
|
||||||
pl, pt = ph.lower(), toks(ph)
|
|
||||||
ranked = []
|
|
||||||
for kid, ktitel in re.findall(r"- atom (\d+): (.+?) —", body):
|
|
||||||
geteilt = {t for t in toks(ktitel) if t in pl} | {t for t in pt if t in ktitel.lower()}
|
|
||||||
if geteilt:
|
|
||||||
ranked.append((-len(geteilt), len(ktitel), int(kid)))
|
|
||||||
ranked.sort()
|
|
||||||
out.append({"phrase": n, "atom": ranked[0][2] if ranked else None})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _writer(prompt: str):
|
def _writer(prompt: str):
|
||||||
@@ -303,7 +280,7 @@ _HANDLER = {
|
|||||||
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
|
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
|
||||||
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||||
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||||
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung,
|
"Baustein-Ordnung": _baustein_ordnung,
|
||||||
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer,
|
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer,
|
||||||
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix,
|
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix,
|
||||||
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
||||||
|
|||||||
@@ -186,23 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
|
|||||||
|
|
||||||
def _anknuepf_kontext(b: dict) -> str:
|
def _anknuepf_kontext(b: dict) -> str:
|
||||||
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
|
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
|
||||||
desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an,
|
desselben Ziels. Der Writer knüpft an, statt neu zu erklären
|
||||||
statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen)."""
|
(Dopplungs-Schutz zwischen den Durchgängen)."""
|
||||||
rang = LEVEL_RANG.get(b["level"], 1)
|
rang = LEVEL_RANG.get(b["level"], 1)
|
||||||
atome = _atome_von(b["id"])
|
|
||||||
ids = {a["id"] for a in atome}
|
|
||||||
kontext: dict[int, dict] = {}
|
kontext: dict[int, dict] = {}
|
||||||
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
|
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
|
||||||
" ('gemerged','verworfen')", (b["ziel_id"],)):
|
" ('gemerged','verworfen')", (b["ziel_id"],)):
|
||||||
if LEVEL_RANG.get(a["level"], 1) < rang:
|
if LEVEL_RANG.get(a["level"], 1) < rang:
|
||||||
kontext[a["id"]] = a
|
kontext[a["id"]] = a
|
||||||
if ids:
|
|
||||||
marks = ",".join("?" * len(ids))
|
|
||||||
for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom"
|
|
||||||
f" WHERE k.art='braucht' AND k.status='aktiv'"
|
|
||||||
f" AND k.von_atom IN ({marks})", tuple(ids)):
|
|
||||||
if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang:
|
|
||||||
kontext[a["id"]] = a
|
|
||||||
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
|
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import embedding
|
|||||||
import korpus
|
import korpus
|
||||||
import llm
|
import llm
|
||||||
import textkit
|
import textkit
|
||||||
from config import (ANKER_OVERLAP_MERGE, BRAUCHT_KANDIDATEN, LUECKEN_RUNDEN_MAX,
|
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX,
|
||||||
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD,
|
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD,
|
||||||
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
|
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
|
||||||
|
|
||||||
@@ -75,12 +75,10 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False
|
|||||||
if not titel or not definition or typ not in TYPEN:
|
if not titel or not definition or typ not in TYPEN:
|
||||||
continue
|
continue
|
||||||
span = textkit.finde_zitat(chunk, zitat)
|
span = textkit.finde_zitat(chunk, zitat)
|
||||||
braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()]
|
|
||||||
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
||||||
definition=definition,
|
definition=definition,
|
||||||
level=level if level in LEVELS else "M",
|
level=level if level in LEVELS else "M",
|
||||||
status="neu" if span else "ohne_anker",
|
status="neu" if span else "ohne_anker")
|
||||||
braucht=db.j(braucht))
|
|
||||||
if span:
|
if span:
|
||||||
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
|
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
|
||||||
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
||||||
@@ -114,7 +112,7 @@ def _wurzel(atom_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
|
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
|
||||||
"""Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
|
"""Anker wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
|
||||||
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
|
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
|
||||||
if gewinner == verlierer:
|
if gewinner == verlierer:
|
||||||
return
|
return
|
||||||
@@ -123,8 +121,6 @@ def _merge(topic: str, gewinner: int, verlierer: int) -> None:
|
|||||||
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
|
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
|
||||||
return
|
return
|
||||||
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
|
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
|
||||||
braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"])))
|
|
||||||
db.update("atome", "id", gewinner, braucht=db.j(braucht))
|
|
||||||
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
|
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
|
||||||
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
|
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
|
||||||
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
|
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
|
||||||
@@ -246,121 +242,6 @@ def _erster_anker(atom_id: int) -> str:
|
|||||||
return (row["zitat"][:300] if row else "(kein Anker)")
|
return (row["zitat"][:300] if row else "(kein Anker)")
|
||||||
|
|
||||||
|
|
||||||
def _kanten_aufloesen(topic: str) -> None:
|
|
||||||
"""braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite
|
|
||||||
Stufe (Norm allein löste nur 72–79 % auf — der Rest fiel still weg und
|
|
||||||
Ordnung/Level-Logik arbeiteten auf einem lückigen Graph)."""
|
|
||||||
atome = aktive_atome(topic)
|
|
||||||
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
|
|
||||||
je_kern: dict[str, int] = {}
|
|
||||||
for a in atome:
|
|
||||||
if kern := textkit.titel_kern(a["titel"]):
|
|
||||||
je_kern.setdefault(kern, a["id"])
|
|
||||||
for a in atome:
|
|
||||||
for titel in db.uj(a["braucht"]):
|
|
||||||
ziel = je_norm.get(textkit.norm(titel))
|
|
||||||
if not ziel and (kern := textkit.titel_kern(titel)):
|
|
||||||
ziel = je_kern.get(kern)
|
|
||||||
if ziel and ziel != a["id"]:
|
|
||||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
|
||||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
|
||||||
|
|
||||||
|
|
||||||
def _braucht_unaufgeloest(atome: list[dict]) -> list[tuple[dict, str]]:
|
|
||||||
"""(Atom, braucht-Titel), die _kanten_aufloesen (norm/kern) NICHT traf. Selbe
|
|
||||||
Auflösungslogik — was sie fand, ist erledigt und bleibt außen vor."""
|
|
||||||
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
|
|
||||||
je_kern: dict[str, int] = {}
|
|
||||||
for a in atome:
|
|
||||||
if kern := textkit.titel_kern(a["titel"]):
|
|
||||||
je_kern.setdefault(kern, a["id"])
|
|
||||||
offen = []
|
|
||||||
for a in atome:
|
|
||||||
for titel in db.uj(a["braucht"]):
|
|
||||||
ziel = je_norm.get(textkit.norm(titel))
|
|
||||||
if not ziel and (kern := textkit.titel_kern(titel)):
|
|
||||||
ziel = je_kern.get(kern)
|
|
||||||
if not ziel or ziel == a["id"]:
|
|
||||||
offen.append((a, titel))
|
|
||||||
return offen
|
|
||||||
|
|
||||||
|
|
||||||
def _trigramm(s: str) -> set[str]:
|
|
||||||
s = textkit.norm(s)
|
|
||||||
return {s[i:i + 3] for i in range(len(s) - 2)} or {s}
|
|
||||||
|
|
||||||
|
|
||||||
def _tri_dice(a: str, b: str) -> float:
|
|
||||||
"""Zeichen-Trigramm-Dice — fuzzy Titel-Ähnlichkeit, die geteilte Wortstämme UND
|
|
||||||
Kopfnomen erfasst („Approximationsalgorithmus" ~ „Approximativer Algorithmus")."""
|
|
||||||
A, B = _trigramm(a), _trigramm(b)
|
|
||||||
return 2 * len(A & B) / (len(A) + len(B)) if A or B else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _braucht_kandidaten(titel: str, atome: list[dict], selbst_id: int, cap: int) -> list[dict]:
|
|
||||||
"""Expliziter Kandidatengenerator für Kompositum↔Expansion (Lektion 28: Cosinus
|
|
||||||
~0.53, Embedding schlägt sie nie vor). Zulassung (hohe Trefferquote): ein
|
|
||||||
signifikantes Token (≥5 Zeichen) ist Substring des anderen normierten Titels
|
|
||||||
(„algorithmus" ⊂ „approximationsalgorithmus"). Rang nach Trigramm-Dice — der
|
|
||||||
ganze Titel zählt, nicht nur das Kopfnomen, so steht der Grundbegriff vor
|
|
||||||
generischen Distraktoren („Algorithmus"). Der Judge trennt dann fein."""
|
|
||||||
nt = textkit.norm(titel)
|
|
||||||
bt = {t for t in textkit.tokens(titel) if len(t) >= 5}
|
|
||||||
treffer = []
|
|
||||||
for a in atome:
|
|
||||||
if a["id"] == selbst_id:
|
|
||||||
continue
|
|
||||||
na = textkit.norm(a["titel"])
|
|
||||||
at = {t for t in textkit.tokens(a["titel"]) if len(t) >= 5}
|
|
||||||
if {t for t in at if t in nt} | {t for t in bt if t in na}:
|
|
||||||
treffer.append((_tri_dice(titel, a["titel"]), a["id"], a))
|
|
||||||
treffer.sort(key=lambda x: (-x[0], x[1]))
|
|
||||||
return [a for _, _, a in treffer[:cap]]
|
|
||||||
|
|
||||||
|
|
||||||
async def _braucht_fallback(ctx: llm.Kontext) -> None:
|
|
||||||
"""braucht-Titel, die norm/kern nicht auflösten, per Judge auf Atome mappen.
|
|
||||||
Deterministischer Substring-Prefilter (Kandidaten) → EIN Judge (n=1) als
|
|
||||||
Präzisions-Gate. braucht-Kanten sind reversibel/billiger als Merges → kein Panel
|
|
||||||
(Lektion 78). Im Zweifel null: eine Falsch-Kante verschiebt Ordnung + Level.
|
|
||||||
Idempotent (INSERT OR IGNORE); läuft nur in bauen, damit ein Bausteine-Reset ihn
|
|
||||||
ohne Re-Extraktion mitnimmt."""
|
|
||||||
topic = ctx.topic
|
|
||||||
atome = aktive_atome(topic)
|
|
||||||
offen = _braucht_unaufgeloest(atome)
|
|
||||||
if not offen:
|
|
||||||
return
|
|
||||||
angaben = [(a, titel, kand) for a, titel in offen
|
|
||||||
if (kand := _braucht_kandidaten(titel, atome, a["id"], BRAUCHT_KANDIDATEN))]
|
|
||||||
if not angaben:
|
|
||||||
log.info("braucht-Fallback: %d unaufgelöste Titel, keine Kandidaten", len(offen))
|
|
||||||
return
|
|
||||||
geloest = 0
|
|
||||||
|
|
||||||
async def chunk_loesen(chunk: list) -> None:
|
|
||||||
nonlocal geloest
|
|
||||||
liste = "\n\n".join(
|
|
||||||
f"ANGABE {n}: Voraussetzung „{titel}“ (gebraucht von Atom {a['id']}"
|
|
||||||
f" „{a['titel']}“)\nKandidaten:\n"
|
|
||||||
+ "\n".join(f" - atom {k['id']}: {k['titel']} — {k['definition']}" for k in kand)
|
|
||||||
for n, (a, titel, kand) in enumerate(chunk, 1))
|
|
||||||
res = await llm.call(ctx, stage="braucht", template="Braucht-Aufloesung",
|
|
||||||
werte={"angaben": liste}, role="judge",
|
|
||||||
n=len(chunk), item=f"br{chunk[0][0]['id']}", erwartet=list)
|
|
||||||
wahl = {e.get("phrase"): e.get("atom") for e in res or [] if isinstance(e, dict)}
|
|
||||||
for n, (a, titel, kand) in enumerate(chunk, 1):
|
|
||||||
zid = wahl.get(n)
|
|
||||||
if zid in {k["id"] for k in kand} and zid != a["id"]:
|
|
||||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
|
||||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], zid))
|
|
||||||
geloest += 1
|
|
||||||
|
|
||||||
chunks = [angaben[i:i + PAAR_CHUNK] for i in range(0, len(angaben), PAAR_CHUNK)]
|
|
||||||
await llm.alle(chunk_loesen(c) for c in chunks)
|
|
||||||
log.info("braucht-Fallback: %d/%d Titel aufgelöst (%d ohne Kandidaten)",
|
|
||||||
geloest, len(offen), len(offen) - len(angaben))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
|
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
|
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
|
||||||
@@ -392,7 +273,6 @@ async def bauen(ctx: llm.Kontext) -> None:
|
|||||||
await llm.alle(_extrahiere_quelle(ctx, q) for q in offene)
|
await llm.alle(_extrahiere_quelle(ctx, q) for q in offene)
|
||||||
_anker_dedup(ctx.topic)
|
_anker_dedup(ctx.topic)
|
||||||
await _judge_dedup(ctx)
|
await _judge_dedup(ctx)
|
||||||
_kanten_aufloesen(ctx.topic)
|
|
||||||
await _soll_zuordnen(ctx)
|
await _soll_zuordnen(ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -403,10 +283,13 @@ _TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe
|
|||||||
|
|
||||||
|
|
||||||
def _titel_kaputt(titel: str) -> bool:
|
def _titel_kaputt(titel: str) -> bool:
|
||||||
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript)
|
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript),
|
||||||
und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |")."""
|
abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |") und LaTeX-Markup
|
||||||
|
(Titel sind Identifikatoren — Board/Dedup/Verweise rendern kein KaTeX)."""
|
||||||
if _TITEL_KATALOG.match(titel):
|
if _TITEL_KATALOG.match(titel):
|
||||||
return True
|
return True
|
||||||
|
if "$" in titel or "\\" in titel:
|
||||||
|
return True
|
||||||
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "–", "(", "{", "⊆", "=")):
|
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "–", "(", "{", "⊆", "=")):
|
||||||
return True
|
return True
|
||||||
return titel.count("(") != titel.count(")")
|
return titel.count("(") != titel.count(")")
|
||||||
@@ -480,7 +363,6 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
if bewegt:
|
if bewegt:
|
||||||
_anker_dedup(ctx.topic)
|
_anker_dedup(ctx.topic)
|
||||||
await _judge_dedup(ctx)
|
await _judge_dedup(ctx)
|
||||||
_kanten_aufloesen(ctx.topic)
|
|
||||||
await _soll_zuordnen(ctx)
|
await _soll_zuordnen(ctx)
|
||||||
return bewegt
|
return bewegt
|
||||||
|
|
||||||
@@ -658,7 +540,7 @@ async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
|
|||||||
offset = text.find(fenster)
|
offset = text.find(fenster)
|
||||||
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
||||||
definition=definition, level="M", status="neu",
|
definition=definition, level="M", status="neu",
|
||||||
soll_id=soll_id, braucht=db.j([]))
|
soll_id=soll_id)
|
||||||
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
|
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
|
||||||
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
||||||
neu = True
|
neu = True
|
||||||
|
|||||||
@@ -13,16 +13,40 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|||||||
_UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})')
|
_UNGUELTIGES_ESCAPE = re.compile(r'\\(?![\"\\/bfnrt]|u[0-9a-fA-F]{4})')
|
||||||
|
|
||||||
|
|
||||||
|
_ESCAPE_JE_ZEICHEN = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
||||||
|
|
||||||
|
|
||||||
|
def _iterativ_reparieren(s: str):
|
||||||
|
"""Positionsgenaue Reparatur am ORIGINAL (das Regex-Doubling beschädigt
|
||||||
|
\\\\x-Folgen): rohe Kontrollzeichen in Strings escapen (Modelle zitieren
|
||||||
|
mehrzeilige Passagen wörtlich — Markup-Zitat-Regel), ungültige Escapes
|
||||||
|
doubeln. Iterativ an der jeweils ersten Fehlerstelle, nur im Fehlerfall."""
|
||||||
|
for _ in range(300):
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
if e.pos >= len(s):
|
||||||
|
return None
|
||||||
|
if "Invalid control character" in e.msg:
|
||||||
|
s = s[:e.pos] + _ESCAPE_JE_ZEICHEN.get(s[e.pos], " ") + s[e.pos + 1:]
|
||||||
|
elif "Invalid \\escape" in e.msg:
|
||||||
|
s = s[:e.pos] + "\\" + s[e.pos:]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _loads(s: str):
|
def _loads(s: str):
|
||||||
"""json.loads mit EINER Reparaturstufe für rohe LaTeX-Backslashes. Valides
|
"""json.loads mit Reparaturstufen für rohe LaTeX-Backslashes und rohe
|
||||||
JSON durchläuft unverändert (Reparatur nur im Fehlerfall)."""
|
Kontrollzeichen in Strings. Valides JSON durchläuft unverändert
|
||||||
|
(Reparatur nur im Fehlerfall)."""
|
||||||
try:
|
try:
|
||||||
return json.loads(s)
|
return json.loads(s)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
return json.loads(_UNGUELTIGES_ESCAPE.sub(r"\\\\", s))
|
return json.loads(_UNGUELTIGES_ESCAPE.sub(r"\\\\", s))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return _iterativ_reparieren(s)
|
||||||
|
|
||||||
|
|
||||||
def parse(text: str):
|
def parse(text: str):
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class LaufPause(Exception):
|
|||||||
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
|
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
|
||||||
|
|
||||||
|
|
||||||
|
class ManuellePause(LaufPause):
|
||||||
|
"""Nutzer-Pause: Wartende stoppen, Laufende auslaufen lassen (alle() drainiert
|
||||||
|
statt zu canceln), dann endet der Lauf als 'paused' — Resume via Start."""
|
||||||
|
|
||||||
|
|
||||||
class Kontext:
|
class Kontext:
|
||||||
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
|
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
|
||||||
|
|
||||||
@@ -57,9 +62,16 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
|
|||||||
role=role, capabilities=caps)
|
role=role, capabilities=caps)
|
||||||
|
|
||||||
haupt = asyncio.ensure_future(einer(key))
|
haupt = asyncio.ensure_future(einer(key))
|
||||||
|
try:
|
||||||
if not schwelle:
|
if not schwelle:
|
||||||
return await haupt
|
return await haupt
|
||||||
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Stop cancelt nur den WARTENDEN — der ensure_future-Task liefe sonst
|
||||||
|
# verwaist weiter (aak: 10 Extraktions-Calls überlebten Stop, hielten
|
||||||
|
# API-Slots und zogen Tokens, bis der Timeout sie erlöste)
|
||||||
|
haupt.cancel()
|
||||||
|
raise
|
||||||
if fertig:
|
if fertig:
|
||||||
return haupt.result() # kein Zwilling gestartet → kein Hedge-Log
|
return haupt.result() # kein Zwilling gestartet → kein Hedge-Log
|
||||||
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
|
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
|
||||||
@@ -129,6 +141,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
|||||||
tokens, err = res.tokens, res.err
|
tokens, err = res.tokens, res.err
|
||||||
wait_ms = int(res.wait_s * 1000)
|
wait_ms = int(res.wait_s * 1000)
|
||||||
model = res.model
|
model = res.model
|
||||||
|
if res.err == "pausiert": # Nutzer-Pause: kein Retry, Lauf sauber beenden
|
||||||
|
status = "pause"
|
||||||
|
raise ManuellePause("manuell pausiert")
|
||||||
if res.ok:
|
if res.ok:
|
||||||
if erwartet is str:
|
if erwartet is str:
|
||||||
status = "ok"
|
status = "ok"
|
||||||
@@ -143,6 +158,9 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
|||||||
status = "ok"
|
status = "ok"
|
||||||
return daten
|
return daten
|
||||||
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
|
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
|
||||||
|
elif "stop=max_tokens" in res.err:
|
||||||
|
status = "cap" # Output-Cap: gleicher Prompt → gleiches Cap,
|
||||||
|
err = res.err # Neuversuch ist deterministisch sinnlos
|
||||||
elif _ist_infra(res.err):
|
elif _ist_infra(res.err):
|
||||||
status = "infra"
|
status = "infra"
|
||||||
else:
|
else:
|
||||||
@@ -159,6 +177,11 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
|||||||
wait_ms=wait_ms,
|
wait_ms=wait_ms,
|
||||||
tokens=tokens, meta={"err": err[:300]} if err else None)
|
tokens=tokens, meta={"err": err[:300]} if err else None)
|
||||||
ledger.budget_pruefen(ctx.run_id)
|
ledger.budget_pruefen(ctx.run_id)
|
||||||
|
if status == "cap":
|
||||||
|
# sofort aufgeben — der Aufrufer reagiert strukturell (Extraktion
|
||||||
|
# halbiert den Chunk); 2 Retries verbrannten sonst ~64k Tokens je Fall
|
||||||
|
log.warning("%s: Output-Cap (stop=max_tokens) — kein Neuversuch", key)
|
||||||
|
return None
|
||||||
if status == "infra":
|
if status == "infra":
|
||||||
infra_rest -= 1
|
infra_rest -= 1
|
||||||
if infra_rest < 0:
|
if infra_rest < 0:
|
||||||
@@ -177,10 +200,15 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
|||||||
|
|
||||||
async def alle(coros) -> list:
|
async def alle(coros) -> list:
|
||||||
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
|
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
|
||||||
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter."""
|
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter.
|
||||||
|
Ausnahme ManuellePause: sanft — Laufende zu Ende laufen lassen (Ergebnisse
|
||||||
|
landen noch in der DB), Wartende liefern selbst „pausiert"."""
|
||||||
tasks = [asyncio.ensure_future(c) for c in coros]
|
tasks = [asyncio.ensure_future(c) for c in coros]
|
||||||
try:
|
try:
|
||||||
return await asyncio.gather(*tasks)
|
return await asyncio.gather(*tasks)
|
||||||
|
except ManuellePause:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
raise
|
||||||
except BaseException:
|
except BaseException:
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
if not t.done():
|
if not t.done():
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ async def lauf_stop(topic: str):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/topics/{topic}/pause")
|
||||||
|
async def lauf_pause(topic: str):
|
||||||
|
pipeline.lauf_pausieren(topic)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def _befunde_aktuell(topic: str) -> list[dict]:
|
def _befunde_aktuell(topic: str) -> list[dict]:
|
||||||
"""Offene Befunde des jeweils jüngsten Laufs je Ebene — in zwei billigen Pässen."""
|
"""Offene Befunde des jeweils jüngsten Laufs je Ebene — in zwei billigen Pässen."""
|
||||||
neueste = db.query(
|
neueste = db.query(
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ def aktualisierung_starten(topic: str, ebene: str, tief: bool = False) -> int:
|
|||||||
return run_id
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def lauf_pausieren(topic: str) -> None:
|
||||||
|
"""Sanfte Pause: Wartende + Neue stoppen, Laufende laufen aus. Der Lauf endet
|
||||||
|
als 'paused', sobald der nächste Call die Pause bemerkt (ManuellePause);
|
||||||
|
Start setzt normal fort (abbruch_aufheben hebt die Pause auf)."""
|
||||||
|
agents.pausieren(f"{topic}-")
|
||||||
|
|
||||||
|
|
||||||
def lauf_stoppen(topic: str) -> None:
|
def lauf_stoppen(topic: str) -> None:
|
||||||
agents.abbrechen(f"{topic}-")
|
agents.abbrechen(f"{topic}-")
|
||||||
task = _laeufe.get(topic)
|
task = _laeufe.get(topic)
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ GEWICHTE = {
|
|||||||
"soll_kandidat_offen": 3.0,
|
"soll_kandidat_offen": 3.0,
|
||||||
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
|
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
|
||||||
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
|
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
|
||||||
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
|
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
|
||||||
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
|
"level_mix": 3.0, "kapitel_level": 3.0,
|
||||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
||||||
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
|
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
|
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
|
||||||
(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR:
|
(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der
|
||||||
Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge =
|
Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick)
|
||||||
topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der
|
mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame
|
||||||
schwächsten Kante (deterministischer Fallback)."""
|
von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import inventar
|
|
||||||
import llm
|
import llm
|
||||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
||||||
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
||||||
@@ -24,11 +22,6 @@ def _atome(topic: str) -> list[dict]:
|
|||||||
" ('gemerged','verworfen') ORDER BY id", (topic,))
|
" ('gemerged','verworfen') ORDER BY id", (topic,))
|
||||||
|
|
||||||
|
|
||||||
def _braucht_kanten(topic: str) -> list[dict]:
|
|
||||||
return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
|
|
||||||
(topic,))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Lernziele ─────────────────────────────────────────────────────────────────
|
# ── Lernziele ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
||||||
@@ -78,119 +71,41 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
|||||||
await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items())
|
await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items())
|
||||||
|
|
||||||
|
|
||||||
# ── Zyklen brechen ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None:
|
|
||||||
"""Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben)."""
|
|
||||||
nach = defaultdict(list)
|
|
||||||
for v, z in kanten:
|
|
||||||
nach[v].append(z)
|
|
||||||
farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz
|
|
||||||
eltern: dict[int, int] = {}
|
|
||||||
for start in knoten:
|
|
||||||
if farbe[start]:
|
|
||||||
continue
|
|
||||||
stapel = [(start, iter(nach[start]))]
|
|
||||||
farbe[start] = 1
|
|
||||||
while stapel:
|
|
||||||
v, it = stapel[-1]
|
|
||||||
fortschritt = False
|
|
||||||
for z in it:
|
|
||||||
if z not in farbe:
|
|
||||||
continue
|
|
||||||
if farbe[z] == 0:
|
|
||||||
farbe[z] = 1
|
|
||||||
eltern[z] = v
|
|
||||||
stapel.append((z, iter(nach[z])))
|
|
||||||
fortschritt = True
|
|
||||||
break
|
|
||||||
if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren
|
|
||||||
pfad = [(v, z)]
|
|
||||||
k = v
|
|
||||||
while k != z:
|
|
||||||
pfad.append((eltern[k], k))
|
|
||||||
k = eltern[k]
|
|
||||||
return pfad
|
|
||||||
if not fortschritt:
|
|
||||||
farbe[v] = 2
|
|
||||||
stapel.pop()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _zyklen_brechen(ctx: llm.Kontext) -> None:
|
|
||||||
while True:
|
|
||||||
atome = _atome(ctx.topic)
|
|
||||||
ids = [a["id"] for a in atome]
|
|
||||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
|
|
||||||
if k["von_atom"] in ids and k["zu_atom"] in ids]
|
|
||||||
zyklus = _finde_zyklus(ids, kanten)
|
|
||||||
if not zyklus:
|
|
||||||
return
|
|
||||||
je_id = {a["id"]: a for a in atome}
|
|
||||||
liste = "\n".join(f"{v}→{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}"
|
|
||||||
for v, z in zyklus)
|
|
||||||
res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus",
|
|
||||||
werte={"kanten": liste}, role="judge",
|
|
||||||
item=f"z{zyklus[0][0]}", erwartet=dict)
|
|
||||||
wahl = None
|
|
||||||
if res:
|
|
||||||
paar = (res.get("von"), res.get("zu"))
|
|
||||||
if paar in zyklus:
|
|
||||||
wahl = paar
|
|
||||||
if wahl is None: # deterministischer Fallback: kleinste Kante
|
|
||||||
wahl = min(zyklus)
|
|
||||||
db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?"
|
|
||||||
" AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1]))
|
|
||||||
log.info("Zyklus gebrochen: %s→%s", wahl[0], wahl[1])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
|
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _topo(knoten: list[int], kanten: list[tuple[int, int]],
|
|
||||||
rang: dict[int, tuple]) -> list[int]:
|
|
||||||
"""Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil).
|
|
||||||
Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen
|
|
||||||
sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder
|
|
||||||
(gemessen aak: 22 Atome ohne Baustein)."""
|
|
||||||
kn = set(knoten)
|
|
||||||
vorher = defaultdict(set)
|
|
||||||
nachher = defaultdict(set)
|
|
||||||
for v, z in kanten:
|
|
||||||
if v in kn and z in kn:
|
|
||||||
vorher[v].add(z)
|
|
||||||
nachher[z].add(v)
|
|
||||||
offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k])
|
|
||||||
out = []
|
|
||||||
erledigt: set[int] = set()
|
|
||||||
while offen:
|
|
||||||
k = offen.pop(0)
|
|
||||||
out.append(k)
|
|
||||||
erledigt.add(k)
|
|
||||||
neu = []
|
|
||||||
for n in nachher[k]:
|
|
||||||
if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu:
|
|
||||||
neu.append(n)
|
|
||||||
offen = sorted(offen + neu, key=lambda kk: rang[kk])
|
|
||||||
out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _anker_rang(topic: str) -> dict[int, tuple]:
|
def _anker_rang(topic: str) -> dict[int, tuple]:
|
||||||
"""Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers."""
|
"""Natürliche Quellreihenfolge: (quelle_id, start) des ersten Ankers,
|
||||||
|
stoff-Quellen bevorzugt — Aufgabensammlungen ordnen nicht didaktisch."""
|
||||||
out = {}
|
out = {}
|
||||||
for a in _atome(topic):
|
for a in _atome(topic):
|
||||||
row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0"
|
row = db.one("SELECT k.quelle_id, k.start FROM anker k"
|
||||||
" ORDER BY quelle_id, start LIMIT 1", (a["id"],))
|
" JOIN quellen q ON q.id=k.quelle_id"
|
||||||
|
" WHERE k.atom_id=? AND k.start>=0"
|
||||||
|
" ORDER BY (q.rolle != 'stoff'), k.quelle_id, k.start LIMIT 1",
|
||||||
|
(a["id"],))
|
||||||
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
|
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
def _baustein_rang(bausteine: list[dict], atome: list[dict],
|
||||||
|
rang: dict[int, tuple]) -> dict[int, tuple]:
|
||||||
|
"""Quellpositions-Prior je Baustein: MEDIAN der Atom-Ränge — robust gegen
|
||||||
|
einzelne Merge-Importe mit fremdem Rang (aak: ein „Komplexitätstheorie"-Atom
|
||||||
|
von Zeichen 464 zog den ETH-Baustein per min() an Position 0)."""
|
||||||
|
b_rang = {}
|
||||||
|
for b in bausteine:
|
||||||
|
raenge = sorted(rang.get(a["id"], (9, 9)) for a in atome
|
||||||
|
if a["baustein_id"] == b["id"])
|
||||||
|
b_rang[b["id"]] = raenge[len(raenge) // 2] if raenge else (9, 9)
|
||||||
|
return b_rang
|
||||||
|
|
||||||
|
|
||||||
|
async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||||
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
||||||
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
|
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
|
||||||
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
|
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
|
||||||
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
|
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
|
||||||
Topo-Reihenfolge splitten ("Teil n")."""
|
Quellreihenfolge splitten ("Teil n")."""
|
||||||
topic = ctx.topic
|
topic = ctx.topic
|
||||||
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
|
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
|
||||||
db.execute("DELETE FROM auftraege WHERE baustein_id IN"
|
db.execute("DELETE FROM auftraege WHERE baustein_id IN"
|
||||||
@@ -198,23 +113,14 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||||
atome = _atome(topic)
|
atome = _atome(topic)
|
||||||
rang = _anker_rang(topic)
|
rang = _anker_rang(topic)
|
||||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)]
|
|
||||||
|
|
||||||
gruppen: dict = defaultdict(list)
|
gruppen: dict = defaultdict(list)
|
||||||
for a in atome:
|
for a in atome:
|
||||||
gruppen[(a["ziel_id"], a["level"])].append(a)
|
gruppen[(a["ziel_id"], a["level"])].append(a)
|
||||||
|
|
||||||
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
|
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
|
||||||
# Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der
|
# Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine
|
||||||
# Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine
|
# Checkliste (152 feine Punkte hätten sonst keine Merge-Partner).
|
||||||
# Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst
|
|
||||||
# Nachbar in der Quellreihenfolge.
|
|
||||||
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
|
|
||||||
ids1 = {a["id"] for a in g1}
|
|
||||||
ids2 = {a["id"] for a in g2}
|
|
||||||
return sum(1 for v, z in kanten
|
|
||||||
if (v in ids1 and z in ids2) or (v in ids2 and z in ids1))
|
|
||||||
|
|
||||||
def gruppen_rang(g: list[dict]) -> tuple:
|
def gruppen_rang(g: list[dict]) -> tuple:
|
||||||
return min(rang[a["id"]] for a in g)
|
return min(rang[a["id"]] for a in g)
|
||||||
|
|
||||||
@@ -235,8 +141,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
|
|
||||||
def naehe(pp: tuple) -> tuple:
|
def naehe(pp: tuple) -> tuple:
|
||||||
r = gruppen_rang(gruppen[pp])
|
r = gruppen_rang(gruppen[pp])
|
||||||
return (-kanten_score(gruppen[k], gruppen[pp]),
|
return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
||||||
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
|
||||||
len(gruppen[pp]))
|
len(gruppen[pp]))
|
||||||
|
|
||||||
p = min(passende, key=naehe)
|
p = min(passende, key=naehe)
|
||||||
@@ -252,7 +157,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
|
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
|
||||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||||
for (ziel_id, level), gruppe in gruppen.items():
|
for (ziel_id, level), gruppe in gruppen.items():
|
||||||
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
|
ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i])
|
||||||
z = ziele.get(ziel_id, {})
|
z = ziele.get(ziel_id, {})
|
||||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
||||||
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
|
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
|
||||||
@@ -273,55 +178,54 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
for pos, atom_id in enumerate(teil_ids):
|
for pos, atom_id in enumerate(teil_ids):
|
||||||
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
||||||
|
|
||||||
_bausteine_ordnen(topic, kanten, rang)
|
await _bausteine_ordnen(ctx)
|
||||||
|
|
||||||
|
|
||||||
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
|
async def _bausteine_ordnen(ctx: llm.Kontext) -> None:
|
||||||
"""Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet;
|
"""Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global
|
||||||
ord läuft global fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-
|
fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität
|
||||||
Kontiguität bleiben level-blind korrekt). Cross-Level-Kanten zeigen nach der
|
bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior
|
||||||
Konfliktlösung immer auf tiefere Level und sind durch die Blockordnung
|
vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine
|
||||||
automatisch erfüllt. Zyklen deterministisch an der schwächsten Aggregat-Kante
|
exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
|
||||||
gebrochen (wenigste Atom-Kanten)."""
|
(ordnung_fallback, Muster wie kapitel_fallback)."""
|
||||||
|
topic = ctx.topic
|
||||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||||
atome = _atome(topic)
|
atome = _atome(topic)
|
||||||
je_atom = {a["id"]: a["baustein_id"] for a in atome}
|
b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic))
|
||||||
b_level = {b["id"]: b["level"] for b in bausteine}
|
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||||
gewicht: dict = defaultdict(int)
|
|
||||||
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
|
|
||||||
bv, bz = je_atom.get(v), je_atom.get(z)
|
|
||||||
if bv and bz and bv != bz and b_level[bv] == b_level[bz]:
|
|
||||||
gewicht[(bv, bz)] += 1
|
|
||||||
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
|
|
||||||
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
|
|
||||||
b_rang = {}
|
|
||||||
for b in bausteine:
|
|
||||||
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
|
|
||||||
if a["baustein_id"] == b["id"]), default=(9, 9))
|
|
||||||
offset = 0
|
offset = 0
|
||||||
for level in LEVEL_RANG:
|
for level in LEVEL_RANG:
|
||||||
b_ids = [b["id"] for b in bausteine if b["level"] == level]
|
folge = sorted((b for b in bausteine if b["level"] == level),
|
||||||
if not b_ids:
|
key=lambda b: b_rang[b["id"]])
|
||||||
|
if not folge:
|
||||||
continue
|
continue
|
||||||
b_kanten = {k for k in gewicht if b_level[k[0]] == level}
|
liste = "\n".join(f"{i + 1}: {b['titel']} — "
|
||||||
while True:
|
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||||
zyklus = _finde_zyklus(b_ids, list(b_kanten))
|
for i, b in enumerate(folge))
|
||||||
if not zyklus:
|
soll = list(range(1, len(folge) + 1))
|
||||||
break
|
|
||||||
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
|
async def ordnen(hinweis: str, tag: str) -> list[int] | None:
|
||||||
b_kanten.discard(schwach)
|
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
|
||||||
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
|
role="judge", n=len(folge), item=tag, erwartet=list,
|
||||||
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
|
werte={"bausteine": liste, "hinweis": hinweis,
|
||||||
# Ordnung vollständig rückwärts auflösen).
|
"durchgang": DURCHGANG[level]})
|
||||||
bv, bz = schwach
|
perm = [x for x in res or [] if isinstance(x, int)]
|
||||||
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
|
return perm if sorted(perm) == soll else None
|
||||||
" AND status='aktiv'"
|
|
||||||
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
|
perm = await ordnen("", f"ordnung-{level}")
|
||||||
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
|
if perm is None:
|
||||||
(topic, bv, bz))
|
perm = await ordnen(
|
||||||
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
|
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
|
||||||
db.update("bausteine", "id", b_id, ord=offset + pos)
|
f" den Nummern 1–{len(folge)}, jede genau einmal.",
|
||||||
offset += len(b_ids)
|
f"ordnung-{level}-2")
|
||||||
|
art = "judge"
|
||||||
|
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
|
||||||
|
art = "fallback"
|
||||||
|
perm = soll
|
||||||
|
for pos, nr in enumerate(perm):
|
||||||
|
db.update("bausteine", "id", folge[nr - 1]["id"],
|
||||||
|
ord=offset + pos, ordnung=art)
|
||||||
|
offset += len(folge)
|
||||||
|
|
||||||
|
|
||||||
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
||||||
@@ -361,29 +265,6 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
|
|||||||
await llm.alle(einer(z, g) for z, g in gruppen.items())
|
await llm.alle(einer(z, g) for z, g in gruppen.items())
|
||||||
|
|
||||||
|
|
||||||
def _level_konflikte_loesen(topic: str) -> None:
|
|
||||||
"""Deterministisch: Eine Voraussetzung darf nie über dem Level ihres Nutzers
|
|
||||||
liegen — sonst fehlt dem Durchgang sein Fundament. Auflösung durch ABSENKEN
|
|
||||||
der Voraussetzung (sie gehört ins Grundgerüst des Abhängigen); Anheben würde
|
|
||||||
Kerninhalte aus dem E-Durchgang verdrängen. Fixpunkt: Level fallen monoton,
|
|
||||||
terminiert nach max. 2 Stufen je Atom."""
|
|
||||||
level = {a["id"]: a["level"] for a in _atome(topic)}
|
|
||||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)
|
|
||||||
if k["von_atom"] in level and k["zu_atom"] in level]
|
|
||||||
gesenkt = 0
|
|
||||||
geaendert = True
|
|
||||||
while geaendert:
|
|
||||||
geaendert = False
|
|
||||||
for v, z in kanten: # v braucht z ⇒ level(z) ≤ level(v)
|
|
||||||
if LEVEL_RANG[level[z]] > LEVEL_RANG[level[v]]:
|
|
||||||
level[z] = level[v]
|
|
||||||
db.update("atome", "id", z, level=level[v])
|
|
||||||
gesenkt += 1
|
|
||||||
geaendert = True
|
|
||||||
if gesenkt:
|
|
||||||
log.info("Level-Konflikte: %d Voraussetzungen abgesenkt", gesenkt)
|
|
||||||
|
|
||||||
|
|
||||||
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
|
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
|
||||||
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
|
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
|
||||||
@@ -469,12 +350,8 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
|||||||
async def bauen(ctx: llm.Kontext) -> None:
|
async def bauen(ctx: llm.Kontext) -> None:
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
await _ziele_bilden(ctx)
|
await _ziele_bilden(ctx)
|
||||||
await inventar._braucht_fallback(ctx) # Kompositum↔Expansion-braucht-Titel nachziehen,
|
|
||||||
# bevor Zyklen/Level auf dem Graph rechnen
|
|
||||||
await _zyklen_brechen(ctx)
|
|
||||||
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||||||
_level_konflikte_loesen(ctx.topic)
|
await _bausteine_schneiden(ctx)
|
||||||
_bausteine_schneiden(ctx)
|
|
||||||
await _kapitel_bilden(ctx)
|
await _kapitel_bilden(ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -503,13 +380,6 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
befunde.append({"art": "level_mix", "item": str(a["id"]),
|
befunde.append({"art": "level_mix", "item": str(a["id"]),
|
||||||
"detail": f"Atom {a['level']} in Baustein {b['level']}"
|
"detail": f"Atom {a['level']} in Baustein {b['level']}"
|
||||||
f" ({b['titel']})"})
|
f" ({b['titel']})"})
|
||||||
level_von = {a["id"]: a["level"] for a in atome}
|
|
||||||
for k in _braucht_kanten(ctx.topic): # Voraussetzung nie über dem Nutzer-Level
|
|
||||||
lv, lz = level_von.get(k["von_atom"]), level_von.get(k["zu_atom"])
|
|
||||||
if lv and lz and LEVEL_RANG[lz] > LEVEL_RANG[lv]:
|
|
||||||
befunde.append({"art": "level_konflikt", "item": str(k["id"]),
|
|
||||||
"detail": f"Atom {k['von_atom']} ({lv}) braucht"
|
|
||||||
f" {k['zu_atom']} ({lz})"})
|
|
||||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
||||||
kap_ids = {k["id"] for k in kaps}
|
kap_ids = {k["id"] for k in kaps}
|
||||||
kap_level = {k["id"]: k["level"] for k in kaps}
|
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||||
@@ -541,18 +411,11 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
if k["ord"] != letzte:
|
if k["ord"] != letzte:
|
||||||
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
|
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
|
||||||
"detail": k["titel"]})
|
"detail": k["titel"]})
|
||||||
ord_von = {b["id"]: b["ord"] for b in bausteine}
|
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback)
|
||||||
for k in _braucht_kanten(ctx.topic):
|
for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}):
|
||||||
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
|
befunde.append({"art": "ordnung_fallback", "item": lv,
|
||||||
bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None)
|
"detail": f"Durchgang {lv}: Judge lieferte keine gültige"
|
||||||
if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0):
|
f" Permutation — Quellreihenfolge übernommen"})
|
||||||
befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]),
|
|
||||||
"detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"})
|
|
||||||
ids = [a["id"] for a in atome]
|
|
||||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
|
|
||||||
if k["von_atom"] in ids and k["zu_atom"] in ids]
|
|
||||||
if _finde_zyklus(ids, kanten):
|
|
||||||
befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"})
|
|
||||||
return befunde
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
@@ -561,9 +424,7 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
if not befunde:
|
if not befunde:
|
||||||
return False
|
return False
|
||||||
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
||||||
await _zyklen_brechen(ctx) # fängt Zyklen
|
|
||||||
await _level_kalibrieren(ctx)
|
await _level_kalibrieren(ctx)
|
||||||
_level_konflikte_loesen(ctx.topic) # fängt level_konflikt
|
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
|
||||||
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, Vorwärts-Kanten, level_mix
|
|
||||||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -47,9 +47,48 @@ def _normiert_mit_map(text: str) -> tuple[str, list[int]]:
|
|||||||
return "".join(out), mapping
|
return "".join(out), mapping
|
||||||
|
|
||||||
|
|
||||||
|
# LaTeX-Kontrollwörter für den Vergleich neutralisieren: das Modell zitiert die
|
||||||
|
# GERENDERTE Lesart („k ∈ ℕ"), die tex-Quelle enthält Markup ($k \in \mathbb{N}$)
|
||||||
|
# — Anker-Identität muss unter diesem Rendering invariant sein (aak: 57/146 Atome
|
||||||
|
# ohne Anker). Griechische Befehle → Unicode-Buchstabe (überlebt die alnum-Faltung
|
||||||
|
# auf beiden Seiten wie das σ im Zitat); alle anderen Befehle fallen weg — ihre
|
||||||
|
# gerenderten Symbole (∈, ⊆, ≥) verwirft die alnum-Faltung ebenfalls.
|
||||||
|
# \begin{itemize} usw. KOMPLETT schlucken (erste Alternative) — sonst bleibt der
|
||||||
|
# Umgebungsname als Residuum in der Faltung („itemize") und Zitate über
|
||||||
|
# Listenstarts scheitern; danach normale Befehlswörter
|
||||||
|
_LATEX_WORT = re.compile(r"\\(?:begin|end)\{[^}]*\}|\\[a-zA-Z]+")
|
||||||
|
_GRIECHISCH = {g: chr(c) for g, c in (
|
||||||
|
("alpha", 0x3B1), ("beta", 0x3B2), ("gamma", 0x3B3), ("delta", 0x3B4),
|
||||||
|
("epsilon", 0x3B5), ("varepsilon", 0x3B5), ("zeta", 0x3B6), ("eta", 0x3B7),
|
||||||
|
("theta", 0x3B8), ("iota", 0x3B9), ("kappa", 0x3BA), ("lambda", 0x3BB),
|
||||||
|
("mu", 0x3BC), ("nu", 0x3BD), ("xi", 0x3BE), ("pi", 0x3C0), ("rho", 0x3C1),
|
||||||
|
("sigma", 0x3C3), ("tau", 0x3C4), ("phi", 0x3C6), ("varphi", 0x3C6),
|
||||||
|
("chi", 0x3C7), ("psi", 0x3C8), ("omega", 0x3C9),
|
||||||
|
("Gamma", 0x393), ("Delta", 0x394), ("Theta", 0x398), ("Lambda", 0x39B),
|
||||||
|
("Xi", 0x39E), ("Pi", 0x3A0), ("Sigma", 0x3A3), ("Phi", 0x3A6),
|
||||||
|
("Psi", 0x3A8), ("Omega", 0x3A9))}
|
||||||
|
|
||||||
|
|
||||||
|
def _latex_falten(text: str) -> list[tuple[str, int]]:
|
||||||
|
"""→ (Zeichen, Original-Position)-Paare mit neutralisierten LaTeX-Befehlen."""
|
||||||
|
paare: list[tuple[str, int]] = []
|
||||||
|
pos = 0
|
||||||
|
for m in _LATEX_WORT.finditer(text):
|
||||||
|
for i in range(pos, m.start()):
|
||||||
|
paare.append((text[i], i))
|
||||||
|
if "{" not in m.group(0): # Umgebungen komplett schlucken
|
||||||
|
for ch in _GRIECHISCH.get(m.group(0)[1:], ""):
|
||||||
|
paare.append((ch, m.start()))
|
||||||
|
pos = m.end()
|
||||||
|
for i in range(pos, len(text)):
|
||||||
|
paare.append((text[i], i))
|
||||||
|
return paare
|
||||||
|
|
||||||
|
|
||||||
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
||||||
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
|
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
|
||||||
Original-Positionen. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren
|
Original-Positionen, LaTeX-Befehle neutralisiert (s. o.). Für die dritte
|
||||||
|
Matching-Stufe: PDF-Extrakte und Reader variieren
|
||||||
Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt
|
Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt
|
||||||
gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute
|
gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute
|
||||||
(„a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen
|
(„a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen
|
||||||
@@ -57,7 +96,7 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
|||||||
ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
|
ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
mapping: list[int] = []
|
mapping: list[int] = []
|
||||||
for i, c in enumerate(text):
|
for c, i in _latex_falten(text):
|
||||||
for cn in unicodedata.normalize("NFKD", c).casefold():
|
for cn in unicodedata.normalize("NFKD", c).casefold():
|
||||||
if cn.isalnum() and not unicodedata.combining(cn):
|
if cn.isalnum() and not unicodedata.combining(cn):
|
||||||
out.append(cn)
|
out.append(cn)
|
||||||
@@ -77,7 +116,8 @@ def _casefold_mit_map(text: str) -> tuple[str, list[int]]:
|
|||||||
return "".join(out), mapping
|
return "".join(out), mapping
|
||||||
|
|
||||||
|
|
||||||
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
|
_LOCKER_MIN_ZEICHEN = 20 # darunter: nur bei EINDEUTIGEM Vorkommen matchen
|
||||||
|
_LOCKER_MIN_EINDEUTIG = 10 # darunter: nie (zu wenig Signal, auch wenn einmalig)
|
||||||
|
|
||||||
|
|
||||||
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
|
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
|
||||||
@@ -107,11 +147,24 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
|
|||||||
return start, ende
|
return start, ende
|
||||||
hay_l, map_l = _locker_mit_map(text)
|
hay_l, map_l = _locker_mit_map(text)
|
||||||
ndl_l, _ = _locker_mit_map(zitat)
|
ndl_l, _ = _locker_mit_map(zitat)
|
||||||
|
if len(ndl_l) < _LOCKER_MIN_EINDEUTIG:
|
||||||
|
return None
|
||||||
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
|
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
|
||||||
|
# Eindeutigkeit war der eigentliche Grund des Längen-Gates — direkt
|
||||||
|
# prüfen statt Proxy-Länge: kurze Formel-Zitate ($LPT(I)=\OPT(I)$)
|
||||||
|
# sind oft einmalig und damit sichere Anker. Kein Fuzzy für Kurze.
|
||||||
|
if hay_l.count(ndl_l) != 1:
|
||||||
return None
|
return None
|
||||||
pos = hay_l.find(ndl_l)
|
pos = hay_l.find(ndl_l)
|
||||||
|
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
|
||||||
|
pos = hay_l.find(ndl_l)
|
||||||
if pos < 0:
|
if pos < 0:
|
||||||
return _fuzzy_span(hay_l, map_l, ndl_l)
|
span = _fuzzy_span(hay_l, map_l, ndl_l)
|
||||||
|
# Negations-Guard auch für Stufe 4: eine eingefügte Negation ist nur
|
||||||
|
# ~5 Zeichen Distanz — semantisch aber das Gegenteil (nie ankern)
|
||||||
|
if span and negations_menge(text[span[0]:span[1]]) != negations_menge(zitat):
|
||||||
|
span = None
|
||||||
|
return span or _wort_span(text, zitat)
|
||||||
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
|
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +191,108 @@ def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | N
|
|||||||
return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1
|
return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stufe 5: Wort-Alignment ──────────────────────────────────────────────────
|
||||||
|
# Modelle zitieren semantisch, nie byte-treu: \cdot → „mal", \sqrt{} → „sqrt()",
|
||||||
|
# kleine Auslassungen. Auf Zeichen-Ebene sprengt jede dieser Abweichungen die
|
||||||
|
# 8-%-Schranke — auf WORT-Ebene ist es je 1 Edit. Echte Paraphrasen bleiben
|
||||||
|
# draußen: dort sind die Wörter selbst anders (Wort-Distanz > Schranke).
|
||||||
|
|
||||||
|
WORT_MIN = 8 # kürzere Zitate: Wort-Matching nicht mehr eindeutig genug
|
||||||
|
WORT_FEHLERQUOTE = 0.25 # max. Wort-Edits als Anteil der Zitat-Wortzahl
|
||||||
|
|
||||||
|
|
||||||
|
def _wort_folge(text: str) -> tuple[list[str], list[tuple[int, int]]]:
|
||||||
|
"""Gefaltete Wörter + (start, ende)-Spans im Original. Gleiche Faltung wie
|
||||||
|
Stufe 3 (LaTeX neutralisiert, NFKD-alnum), aber mit Wortgrenzen."""
|
||||||
|
woerter: list[str] = []
|
||||||
|
spans: list[tuple[int, int]] = []
|
||||||
|
aktuell: list[str] = []
|
||||||
|
w_start = w_ende = -1
|
||||||
|
for c, i in _latex_falten(text):
|
||||||
|
emittiert = False
|
||||||
|
for cn in unicodedata.normalize("NFKD", c).casefold():
|
||||||
|
if cn.isalnum() and not unicodedata.combining(cn):
|
||||||
|
if not aktuell:
|
||||||
|
w_start = i
|
||||||
|
aktuell.append(cn)
|
||||||
|
w_ende = i
|
||||||
|
emittiert = True
|
||||||
|
if not emittiert and aktuell:
|
||||||
|
woerter.append("".join(aktuell))
|
||||||
|
spans.append((w_start, w_ende + 1))
|
||||||
|
aktuell = []
|
||||||
|
if aktuell:
|
||||||
|
woerter.append("".join(aktuell))
|
||||||
|
spans.append((w_start, w_ende + 1))
|
||||||
|
return woerter, spans
|
||||||
|
|
||||||
|
|
||||||
|
def _wort_distanz(a: list[str], b: list[str], limit: int) -> int:
|
||||||
|
"""Levenshtein über Wortfolgen, mit Abbruch oberhalb von limit."""
|
||||||
|
if abs(len(a) - len(b)) > limit:
|
||||||
|
return limit + 1
|
||||||
|
vorher = list(range(len(b) + 1))
|
||||||
|
for i, wa in enumerate(a, 1):
|
||||||
|
zeile = [i]
|
||||||
|
minimum = i
|
||||||
|
for j, wb in enumerate(b, 1):
|
||||||
|
d = min(vorher[j] + 1, zeile[j - 1] + 1, vorher[j - 1] + (wa != wb))
|
||||||
|
zeile.append(d)
|
||||||
|
minimum = min(minimum, d)
|
||||||
|
if minimum > limit:
|
||||||
|
return limit + 1
|
||||||
|
vorher = zeile
|
||||||
|
return vorher[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _negationen(woerter: list[str]) -> frozenset[str]:
|
||||||
|
return frozenset(w for w in woerter if w in _NEGATION or w.startswith("nicht"))
|
||||||
|
|
||||||
|
|
||||||
|
def _wort_span(text: str, zitat: str) -> tuple[int, int] | None:
|
||||||
|
"""Bestes Fenster im Text mit minimaler WORT-Edit-Distanz zum Zitat.
|
||||||
|
Kandidaten über die seltensten Zitat-Wörter (kein Voll-Scan); Schranken:
|
||||||
|
Fehlerquote, Eindeutigkeits-Guard, Negations-Gleichheit
|
||||||
|
(Falsch-Anker >> fehlender Anker)."""
|
||||||
|
ndl, _ = _wort_folge(zitat)
|
||||||
|
if len(ndl) < WORT_MIN:
|
||||||
|
return None
|
||||||
|
hay, spans = _wort_folge(text)
|
||||||
|
limit = max(2, int(len(ndl) * WORT_FEHLERQUOTE))
|
||||||
|
vorkommen: dict[str, list[int]] = {}
|
||||||
|
for i, w in enumerate(hay):
|
||||||
|
vorkommen.setdefault(w, []).append(i)
|
||||||
|
# seltenste Zitat-Wörter als Kandidaten-Anker (bis 3 Wörter, je ≤ 50 Stellen)
|
||||||
|
selten = sorted(((len(vorkommen.get(w, [])), idx, w) for idx, w in enumerate(ndl)
|
||||||
|
if 0 < len(vorkommen.get(w, [])) <= 50))[:3]
|
||||||
|
starts: set[int] = set()
|
||||||
|
for _, idx, w in selten:
|
||||||
|
for p in vorkommen[w]:
|
||||||
|
for versatz in (-2, -1, 0, 1, 2):
|
||||||
|
s = p - idx + versatz
|
||||||
|
if 0 <= s < len(hay):
|
||||||
|
starts.add(s)
|
||||||
|
treffer: list[tuple[int, int, int]] = [] # (dist, start, ende)
|
||||||
|
for s in starts:
|
||||||
|
for laenge in range(max(WORT_MIN, len(ndl) - 2), len(ndl) + 4):
|
||||||
|
fenster = hay[s:s + laenge]
|
||||||
|
if len(fenster) < WORT_MIN:
|
||||||
|
continue
|
||||||
|
d = _wort_distanz(ndl, fenster, limit)
|
||||||
|
if d <= limit:
|
||||||
|
treffer.append((d, s, s + len(fenster)))
|
||||||
|
if not treffer:
|
||||||
|
return None
|
||||||
|
best = min(treffer)
|
||||||
|
schranke = best[0] + max(1, int(len(ndl) * 0.08))
|
||||||
|
for d, s, e in treffer:
|
||||||
|
if (e <= best[1] or s >= best[2]) and d <= schranke:
|
||||||
|
return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen
|
||||||
|
if _negationen(ndl) != _negationen(hay[best[1]:best[2]]):
|
||||||
|
return None # Negations-Guard: nie „regulär" auf „nicht regulär" ankern
|
||||||
|
return spans[best[1]][0], spans[best[2] - 1][1]
|
||||||
|
|
||||||
|
|
||||||
def titel_kern(titel: str) -> str:
|
def titel_kern(titel: str) -> str:
|
||||||
"""Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über
|
"""Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über
|
||||||
Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als
|
Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als
|
||||||
|
|||||||
@@ -106,15 +106,18 @@ def importieren(d: dict) -> None:
|
|||||||
z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
|
z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
|
||||||
k_map = _einfuegen("kapitel", d["kapitel"])
|
k_map = _einfuegen("kapitel", d["kapitel"])
|
||||||
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map)
|
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map)
|
||||||
# merged_into zeigt auf atome selbst → zweiphasig
|
# merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus
|
||||||
a_map = _einfuegen("atome", [{**z, "merged_into": None} for z in d["atome"]],
|
# Alt-Exporten verwerfen (Spalte existiert nicht mehr)
|
||||||
|
a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"},
|
||||||
|
"merged_into": None} for z in d["atome"]],
|
||||||
soll_id=s_map, ziel_id=z_map, baustein_id=b_map)
|
soll_id=s_map, ziel_id=z_map, baustein_id=b_map)
|
||||||
for z in d["atome"]:
|
for z in d["atome"]:
|
||||||
if z.get("merged_into") is not None:
|
if z.get("merged_into") is not None:
|
||||||
db.update("atome", "id", a_map[z["id"]],
|
db.update("atome", "id", a_map[z["id"]],
|
||||||
merged_into=a_map.get(z["merged_into"]))
|
merged_into=a_map.get(z["merged_into"]))
|
||||||
_einfuegen("anker", d["anker"], atom_id=a_map, quelle_id=q_map)
|
_einfuegen("anker", d["anker"], atom_id=a_map, quelle_id=q_map)
|
||||||
_einfuegen("kanten", d["kanten"], von_atom=a_map, zu_atom=a_map)
|
_einfuegen("kanten", [z for z in d["kanten"] if z.get("art") != "braucht"],
|
||||||
|
von_atom=a_map, zu_atom=a_map)
|
||||||
ar_map = _einfuegen("artefakte", d["artefakte"], atom_id=a_map)
|
ar_map = _einfuegen("artefakte", d["artefakte"], atom_id=a_map)
|
||||||
for z in d["leitner"]: # PK ist artefakt_id — kein eigenes id-Feld
|
for z in d["leitner"]: # PK ist artefakt_id — kein eigenes id-Feld
|
||||||
z = dict(z)
|
z = dict(z)
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ async function stoppen() {
|
|||||||
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
|
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pausieren() {
|
||||||
|
try { fehler.value = ''; await api.pause(topic.value); await stateLaden() }
|
||||||
|
catch (e) { fehler.value = String(e) }
|
||||||
|
}
|
||||||
|
|
||||||
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
|
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
|
||||||
const uebertragung = ref('')
|
const uebertragung = ref('')
|
||||||
|
|
||||||
@@ -186,6 +191,8 @@ function runBadge(run) {
|
|||||||
</div>
|
</div>
|
||||||
<template v-if="tab === 'board'">
|
<template v-if="tab === 'board'">
|
||||||
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
|
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
|
||||||
|
<button class="sekundaer" title="Wartende stoppen, Laufende laufen aus — Resume via Start"
|
||||||
|
@click="pausieren" :disabled="state?.run?.status !== 'running'">Pause</button>
|
||||||
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
|
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="transferOk && tab === 'board'">
|
<template v-if="transferOk && tab === 'board'">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const api = {
|
|||||||
start: (topic, budget) =>
|
start: (topic, budget) =>
|
||||||
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||||
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
|
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
|
||||||
|
pause: (topic) => req(`/api/topics/${e(topic)}/pause`, { method: 'POST', body: '{}' }),
|
||||||
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
|
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||||
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
|
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||||
setAuto: (topic, ebene, an) =>
|
setAuto: (topic, ebene, an) =>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Regeln:
|
|||||||
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
|
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
|
||||||
diesem Abschnitt: weglassen.
|
diesem Abschnitt: weglassen.
|
||||||
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
|
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
|
||||||
|
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
|
||||||
|
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
|
||||||
|
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
|
||||||
|
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
|
||||||
|
|
||||||
ATOME:
|
ATOME:
|
||||||
{atome}
|
{atome}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Regeln:
|
|||||||
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
|
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
|
||||||
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
|
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
|
||||||
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
|
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
|
||||||
|
KEIN LaTeX-Markup im Titel — Formeln verbal benennen.
|
||||||
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
|
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
|
||||||
kein Feld außer dem Zitat.
|
kein Feld außer dem Zitat.
|
||||||
- „typ": begriff | aussage | verfahren.
|
- „typ": begriff | aussage | verfahren.
|
||||||
@@ -16,15 +17,18 @@ Regeln:
|
|||||||
| S = Feinheiten/Spezialfälle. Relativ zum restlichen Stoff einstufen.
|
| S = Feinheiten/Spezialfälle. Relativ zum restlichen Stoff einstufen.
|
||||||
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
|
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
|
||||||
dass dieses Konzept geübt wird.
|
dass dieses Konzept geübt wird.
|
||||||
|
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
|
||||||
|
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
|
||||||
|
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
|
||||||
|
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
|
||||||
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
|
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
|
||||||
- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen
|
- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen
|
||||||
sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original.
|
sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original.
|
||||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
|
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
|
||||||
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
|
|
||||||
|
|
||||||
QUELLTEXT:
|
QUELLTEXT:
|
||||||
{text}
|
{text}
|
||||||
|
|
||||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||||
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
|
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "E"}},
|
||||||
{{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]
|
{{"titel": "…", "typ": "verfahren", "definition": "…", "zitat": "…", "level": "M"}}]
|
||||||
|
|||||||
@@ -14,22 +14,25 @@ KEINE Atome. Regeln:
|
|||||||
Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung
|
Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung
|
||||||
bleiben original. Nur das „zitat" bleibt in der Quellsprache.
|
bleiben original. Nur das „zitat" bleibt in der Quellsprache.
|
||||||
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
|
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
|
||||||
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
|
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht. KEIN
|
||||||
|
LaTeX-Markup im Titel — Formeln verbal benennen.
|
||||||
- „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
|
- „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
|
||||||
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
|
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
|
||||||
5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
||||||
|
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
|
||||||
|
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
|
||||||
|
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
|
||||||
|
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
|
||||||
- „level": E = Grundgerüst (Kernbegriffe/Kernaussagen, die man beim ERSTEN Durchgang
|
- „level": E = Grundgerüst (Kernbegriffe/Kernaussagen, die man beim ERSTEN Durchgang
|
||||||
durchs Thema braucht) | M = Standardstoff (Sätze, Verfahren, Standardbeweise)
|
durchs Thema braucht) | M = Standardstoff (Sätze, Verfahren, Standardbeweise)
|
||||||
| S = Feinheiten (Spezialfälle, Beweisdetails, Optimierungen). Relativ zum
|
| S = Feinheiten (Spezialfälle, Beweisdetails, Optimierungen). Relativ zum
|
||||||
restlichen Stoff des Auszugs einstufen — Stufen sind Komplexitätsschichten,
|
restlichen Stoff des Auszugs einstufen — Stufen sind Komplexitätsschichten,
|
||||||
keine Bauchgefühl-Schwierigkeit.
|
keine Bauchgefühl-Schwierigkeit.
|
||||||
- „braucht": Titel anderer Atome DIESES Auszugs, die man vorher verstanden haben muss
|
|
||||||
(nur echte Voraussetzungen, keine bloße Verwandtschaft). Leer, wenn keine.
|
|
||||||
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
|
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
|
||||||
|
|
||||||
QUELLTEXT:
|
QUELLTEXT:
|
||||||
{text}
|
{text}
|
||||||
|
|
||||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||||
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E", "braucht": []}},
|
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "E"}},
|
||||||
{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M", "braucht": ["…"]}}]
|
{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M"}}]
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ Der QUELLTEXT unten enthält die Belegstelle dieses Punkts. Extrahiere die Atome
|
|||||||
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
|
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
|
||||||
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
|
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
|
||||||
- „zitat": Kernpassage WÖRTLICH kopiert (5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
- „zitat": Kernpassage WÖRTLICH kopiert (5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
||||||
|
- Zitat-Disziplin: auch LaTeX-Markup unverändert mitkopieren ($…$, \textsc{{…}},
|
||||||
|
\item) — NICHT in Symbole übersetzen. Bei Listen/mehrschrittigen Verfahren
|
||||||
|
GENAU EINEN zusammenhängenden Ausschnitt zitieren (Einleitungssatz oder ein
|
||||||
|
Listenelement) — niemals mehrere Stellen verbinden oder zusammenfassen.
|
||||||
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
|
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
|
||||||
|
|
||||||
QUELLTEXT:
|
QUELLTEXT:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Katalognummern („Satz 6.26") oder abgerissene Fragmente.
|
|||||||
Gib jedem Atom einen sauberen Titel:
|
Gib jedem Atom einen sauberen Titel:
|
||||||
- Konzeptname aus der Definition, 2–6 Wörter, auf Deutsch.
|
- Konzeptname aus der Definition, 2–6 Wörter, auf Deutsch.
|
||||||
- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende.
|
- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende.
|
||||||
|
- KEIN LaTeX-Markup ($…$, Backslash-Befehle) — Formeln verbal benennen
|
||||||
|
(„Variablen in $\alpha_k$" wird „alpha-Variablen der Kodierung").
|
||||||
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
|
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
|
||||||
|
|
||||||
ATOME:
|
ATOME:
|
||||||
|
|||||||
17
templates/Baustein-Ordnung.md
Normal file
17
templates/Baustein-Ordnung.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- template:Baustein-Ordnung -->
|
||||||
|
Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
|
||||||
|
Durchgang: {durchgang}
|
||||||
|
|
||||||
|
BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials):
|
||||||
|
{bausteine}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Grundbegriffe und Definitionen VOR ihrer ersten Verwendung.
|
||||||
|
- Was ein Baustein inhaltlich voraussetzt, muss vorher behandelt sein.
|
||||||
|
- Die Quellreihenfolge ist ein guter Ausgangspunkt — weiche nur mit
|
||||||
|
didaktischem Grund ab.
|
||||||
|
{hinweis}
|
||||||
|
|
||||||
|
Antworte NUR mit JSON (keine Code-Fences): ALLE Nummern in Lehr-Reihenfolge,
|
||||||
|
jede genau einmal.
|
||||||
|
[1, 2, 3]
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!-- template:Braucht-Aufloesung -->
|
|
||||||
Unten Voraussetzungs-Angaben aus Lern-Atomen. Jede nennt eine PHRASE (ein
|
|
||||||
Konzept, das ein Atom voraussetzt) und eine Liste KANDIDATEN-Atome (id, Titel,
|
|
||||||
Definition), die dieses Konzept meinen KÖNNTEN.
|
|
||||||
|
|
||||||
Entscheide je Angabe: Welches Kandidaten-Atom DEFINIERT genau das Konzept, das die
|
|
||||||
Phrase bezeichnet? Antworte mit dessen `atom`-id — oder `null`, wenn keines eindeutig
|
|
||||||
passt.
|
|
||||||
|
|
||||||
Regeln:
|
|
||||||
- Es geht um KONZEPT-Gleichheit, nicht Wortgleichheit. „Approximationsalgorithmus"
|
|
||||||
(Kompositum) und „Approximativer Algorithmus" (Phrase) sind dasselbe Konzept.
|
|
||||||
- Wähle das Atom, das die Phrase als eigenen GEGENSTAND definiert — nicht ein Atom,
|
|
||||||
das sie nur benutzt oder einen Spezialfall behandelt.
|
|
||||||
- Genau EIN Treffer je Phrase. Passt keiner klar, oder passen mehrere gleich gut →
|
|
||||||
`null`. Eine falsche Kante verschiebt Reihenfolge und Level; im Zweifel `null`.
|
|
||||||
- Antworte für JEDE Angabe.
|
|
||||||
|
|
||||||
ANGABEN:
|
|
||||||
{angaben}
|
|
||||||
|
|
||||||
Antworte NUR mit JSON (keine Code-Fences):
|
|
||||||
[{{"phrase": 1, "atom": 2065}}, {{"phrase": 2, "atom": null}}]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!-- template:Kanten-Zyklus -->
|
|
||||||
Der Voraussetzungs-Graph enthält einen Zyklus (unten die Kanten, Format
|
|
||||||
„von→zu: X braucht Y").
|
|
||||||
|
|
||||||
Wähle die SCHWÄCHSTE Kante — die, deren Voraussetzungs-Behauptung am wenigsten
|
|
||||||
zwingend ist (eher Verwandtschaft als echte Voraussetzung). Genau eine.
|
|
||||||
|
|
||||||
ZYKLUS:
|
|
||||||
{kanten}
|
|
||||||
|
|
||||||
Antworte NUR mit JSON (keine Code-Fences), mit den ids der gewählten Kante:
|
|
||||||
{{"von": 12, "zu": 7}}
|
|
||||||
@@ -31,9 +31,9 @@ async def test_nur_ohne_sieht_fehlende_flashcard():
|
|||||||
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
|
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
|
||||||
topic = topic_anlegen("fcfehlt")
|
topic = topic_anlegen("fcfehlt")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", soll_id=1, braucht=db.j([]))
|
status="neu", soll_id=1)
|
||||||
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
|
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
|
||||||
status="neu", soll_id=1, braucht=db.j([]))
|
status="neu", soll_id=1)
|
||||||
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
|
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
|
||||||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
||||||
inhalt=db.j({"text": "Beispiel"}))
|
inhalt=db.j({"text": "Beispiel"}))
|
||||||
@@ -50,7 +50,7 @@ async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
|
|||||||
topic = topic_anlegen("reverify")
|
topic = topic_anlegen("reverify")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
||||||
inhalt=db.j({"frage": "Was ist X?", "antwort": "Y", "text": ""}))
|
inhalt=db.j({"frage": "Was ist X?", "antwort": "Y", "text": ""}))
|
||||||
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
|
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
|
||||||
@@ -68,7 +68,7 @@ async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
|
|||||||
topic = topic_anlegen("guard")
|
topic = topic_anlegen("guard")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
||||||
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
|
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
@@ -103,7 +103,7 @@ def test_referenziert_quelle_ignoriert_code():
|
|||||||
def test_formen_gate_verwirft_kaputte_tabelle():
|
def test_formen_gate_verwirft_kaputte_tabelle():
|
||||||
topic = topic_anlegen("formgate")
|
topic = topic_anlegen("formgate")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
||||||
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
|
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
|
||||||
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
||||||
@@ -118,7 +118,7 @@ def test_beispiel_einsetzen_tabelle():
|
|||||||
topic = topic_anlegen("bsp-tab")
|
topic = topic_anlegen("bsp-tab")
|
||||||
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
|
status="neu", baustein_id=b, ord=0)
|
||||||
md = "| A | B |\n|---|---|\n| 1 | 2 |"
|
md = "| A | B |\n|---|---|\n| 1 | 2 |"
|
||||||
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
||||||
inhalt=db.j({"form": "tabelle", "tabelle": md}))
|
inhalt=db.j({"form": "tabelle", "tabelle": md}))
|
||||||
@@ -141,7 +141,7 @@ def test_code_syntax_gate():
|
|||||||
def test_formen_gate_verwirft_kaputten_code():
|
def test_formen_gate_verwirft_kaputten_code():
|
||||||
topic = topic_anlegen("codegate")
|
topic = topic_anlegen("codegate")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
||||||
inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"}))
|
inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"}))
|
||||||
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
|
||||||
@@ -156,7 +156,7 @@ async def test_guide_messen_toter_beispiel_marker():
|
|||||||
topic = topic_anlegen("bspqa")
|
topic = topic_anlegen("bspqa")
|
||||||
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
|
status="neu", baustein_id=b, ord=0)
|
||||||
db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv")
|
db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv")
|
||||||
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||||
text_lang=f"<!-- atom: {a} | X -->\n<!-- beispiel: 777 -->\n" + "Wort " * 60)
|
text_lang=f"<!-- atom: {a} | X -->\n<!-- beispiel: 777 -->\n" + "Wort " * 60)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def _baustein(topic, lang_extra=""):
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() + lang_extra
|
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() + lang_extra
|
||||||
db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang,
|
db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang,
|
||||||
text_kompakt="- p")
|
text_kompakt="- p")
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ def test_det_auftraege_blockquote_und_artefakt():
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
|
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
|
||||||
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
|
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
|
||||||
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
||||||
@@ -197,7 +197,7 @@ def test_det_auftraege_neue_checks():
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
wand = "Wort " * 130
|
wand = "Wort " * 130
|
||||||
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
|
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
|
||||||
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
|
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
|
||||||
@@ -231,7 +231,7 @@ async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch):
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
|
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
|
||||||
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
|
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
|
||||||
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
|
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
|
||||||
@@ -256,9 +256,9 @@ def test_marker_titel_erzeugen_keine_auftraege():
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
|
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
|
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
lang = (f"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\n"
|
lang = (f"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\n"
|
||||||
+ "Sauberer Fließtext ohne Formeln. " * 12)
|
+ "Sauberer Fließtext ohne Formeln. " * 12)
|
||||||
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
|
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
|
||||||
@@ -293,7 +293,7 @@ async def test_auftrag_offen_in_messen():
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
|
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
|
||||||
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
|
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
|
||||||
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
|
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
|
||||||
@@ -320,7 +320,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
status="neu", baustein_id=b_id)
|
||||||
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
|
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
|
||||||
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
|
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
|
||||||
text_kompakt="- p")
|
text_kompakt="- p")
|
||||||
@@ -413,7 +413,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b, braucht=db.j([]))
|
status="neu", baustein_id=b)
|
||||||
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
|
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
|
||||||
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
|
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
|
||||||
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
|
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
|
||||||
@@ -433,7 +433,7 @@ async def test_marker_platzieren_deterministisch():
|
|||||||
topic = topic_anlegen("platz")
|
topic = topic_anlegen("platz")
|
||||||
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
|
status="neu", baustein_id=b, ord=0)
|
||||||
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
||||||
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
|
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
|
||||||
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
|
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
|
||||||
@@ -444,3 +444,90 @@ async def test_marker_platzieren_deterministisch():
|
|||||||
# idempotent: zweiter Lauf dupliziert nicht
|
# idempotent: zweiter Lauf dupliziert nicht
|
||||||
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
|
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
|
||||||
out).count("<!-- beispiel:") == 1
|
out).count("<!-- beispiel:") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_latex_gerendert():
|
||||||
|
"""tex-Quellen: das Modell zitiert die gerenderte Lesart (k ∈ ℕ), die Quelle
|
||||||
|
trägt Markup ($k \\in \\mathbb{N}$) — die Faltung macht beide gleich."""
|
||||||
|
text = ("Für das \\textsc{VertexCover} Problem ist neben einem Graphen $G$ eine"
|
||||||
|
" Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und es wird gefragt, ob ein"
|
||||||
|
" Vertex Cover mit Größe höchstens $k$ in $G$ existiert.")
|
||||||
|
zitat = ("Für das VertexCover Problem ist neben einem Graphen G eine Zahl k ∈ ℕ"
|
||||||
|
" gegeben und es wird gefragt, ob ein Vertex Cover mit Größe höchstens k"
|
||||||
|
" in G existiert.")
|
||||||
|
span = textkit.finde_zitat(text, zitat)
|
||||||
|
assert span is not None and text[span[0]:span[1]].startswith("Für das")
|
||||||
|
# griechische Befehle ↔ Unicode-Buchstaben
|
||||||
|
text2 = ("Ein Wort $w \\in \\Sigma^*$ über dem Alphabet $\\Sigma$ ist eine"
|
||||||
|
" endliche Folge von Zeichen aus dem Alphabet.")
|
||||||
|
zitat2 = ("Ein Wort w ∈ Σ* über dem Alphabet Σ ist eine endliche Folge von"
|
||||||
|
" Zeichen aus dem Alphabet.")
|
||||||
|
assert textkit.finde_zitat(text2, zitat2) is not None
|
||||||
|
# LaTeX-Zitat gegen LaTeX-Quelle bleibt symmetrisch matchbar
|
||||||
|
assert textkit.finde_zitat(text, "Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und"
|
||||||
|
" es wird gefragt, ob ein Vertex Cover") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_kurz_nur_bei_eindeutigkeit():
|
||||||
|
"""Kurze Zitate (gefaltet 10–19 Zeichen) matchen nur bei GENAU EINEM
|
||||||
|
Vorkommen — Eindeutigkeit direkt geprüft statt Proxy-Länge."""
|
||||||
|
text = ("Vorher steht Text. Definiere $\\alpha_{\\text{Anfang}} = z_{00} \\wedge"
|
||||||
|
" s_{00}$ als Startbelegung. Nachher steht mehr.")
|
||||||
|
zitat = "α_Anfang = z_00 ∧ s_00" # gerendert, gefaltet 13 Zeichen
|
||||||
|
span = textkit.finde_zitat(text, zitat)
|
||||||
|
assert span is not None and "alpha" in text[span[0]:span[1]]
|
||||||
|
# zweites Vorkommen → mehrdeutig → None
|
||||||
|
assert textkit.finde_zitat(
|
||||||
|
text + " Wieder $\\alpha_{\\text{Anfang}} = z_{00} \\wedge s_{00}$.", zitat) is None
|
||||||
|
# unter 10 gefalteten Zeichen: nie
|
||||||
|
assert textkit.finde_zitat("Es gilt x=y hier.", "x = y") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsonx_repariert_zeilenumbruch_in_string():
|
||||||
|
# rohes mehrzeiliges Zitat (Markup-Zitat-Regel) → Kontrollzeichen-Reparatur
|
||||||
|
assert jsonx.parse('[{"zitat": "\\\\item Zeile eins\nZeile zwei"}]') == [
|
||||||
|
{"zitat": "\\item Zeile eins\nZeile zwei"}]
|
||||||
|
assert jsonx.parse('{"a": "x\n\ny\tz"}') == {"a": "x\n\ny\tz"}
|
||||||
|
# intaktes JSON bleibt unverändert
|
||||||
|
assert jsonx.parse('{"a": "x\\nb"}') == {"a": "x\nb"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_ueber_listenstart():
|
||||||
|
"""\\begin{itemize} darf kein „itemize"-Residuum in der Faltung lassen."""
|
||||||
|
text = ("Ein Algorithmus löst das Problem, wenn für alle $x \\in U$ gilt:\n"
|
||||||
|
"\\begin{itemize}\n\\item $x \\in L$ impliziert $A(x) = 1$,\n"
|
||||||
|
"\\item $x \\notin L$ impliziert $A(x) = 0$.\n\\end{itemize}")
|
||||||
|
zitat = ("Ein Algorithmus löst das Problem, wenn für alle x ∈ U gilt: x ∈ L"
|
||||||
|
" impliziert A(x) = 1, x ∉ L impliziert A(x) = 0.")
|
||||||
|
assert textkit.finde_zitat(text, zitat) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_wortalignment_verbalisierung():
|
||||||
|
"""Stufe 5: Modell verbalisiert Mathe (\\cdot → „mal", \\sqrt → sqrt()) —
|
||||||
|
auf Wort-Ebene wenige Edits → Treffer trotz Zeichen-Chaos."""
|
||||||
|
text = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
|
||||||
|
"$2^{o(|V'|)} \\cdot |I|^{O(1)}$ löst. Durch die Kombination der "
|
||||||
|
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
|
||||||
|
zitat = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
|
||||||
|
"2^{o(|V'|)} mal |I|^{O(1)} löst. Durch die Kombination der "
|
||||||
|
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
|
||||||
|
span = textkit.finde_zitat(text, zitat)
|
||||||
|
assert span is not None and text[span[0]:span[1]].startswith("Angenommen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_wortalignment_paraphrase_bleibt_draussen():
|
||||||
|
"""Echte Umformulierung: Wörter selbst anders → Wort-Distanz über Schranke."""
|
||||||
|
text = ("Dann wird geprüft, ob $|X| \\leq k$ gilt und es wird abgelehnt, "
|
||||||
|
"falls es nicht gilt. Danach wird ein Graph konstruiert, in dem alle "
|
||||||
|
"Knoten entfernt wurden und die Suche neu beginnt.")
|
||||||
|
zitat = ("Prüfe, ob |X| ≤ k, sonst ablehnen. Konstruiere den Graphen ohne "
|
||||||
|
"diese Knoten und starte die Suche von vorn im Verfahren.")
|
||||||
|
assert textkit.finde_zitat(text, zitat) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_finde_zitat_wortalignment_negations_guard():
|
||||||
|
text = ("Die Sprache ist in diesem Modell regulär und wird von einem "
|
||||||
|
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
|
||||||
|
zitat = ("Die Sprache ist in diesem Modell nicht regulär und wird von einem "
|
||||||
|
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
|
||||||
|
assert textkit.finde_zitat(text, zitat) is None
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def _quelle(topic, tmp_path, text, name="q.md"):
|
|||||||
|
|
||||||
def _atom(topic):
|
def _atom(topic):
|
||||||
return db.insert("atome", topic=topic, titel="T", typ="verfahren",
|
return db.insert("atome", topic=topic, titel="T", typ="verfahren",
|
||||||
definition="d", status="neu", braucht=db.j([]))
|
definition="d", status="neu")
|
||||||
|
|
||||||
|
|
||||||
def test_fenster_umgibt_anker(tmp_path):
|
def test_fenster_umgibt_anker(tmp_path):
|
||||||
|
|||||||
@@ -184,3 +184,101 @@ async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
|||||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||||
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
|
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stop_cancelt_verwaisten_haupt_task(monkeypatch):
|
||||||
|
"""Stop cancelt den Wartenden — der ensure_future-Haupt-Task muss MIT
|
||||||
|
sterben, sonst hält er Slot + Tokens bis zum Timeout (aak: 10 Waisen)."""
|
||||||
|
import asyncio
|
||||||
|
import agents
|
||||||
|
import llm
|
||||||
|
from conftest import run_anlegen, topic_anlegen
|
||||||
|
topic = topic_anlegen("orphan")
|
||||||
|
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||||
|
ctx.ebene = "inventar"
|
||||||
|
gestartet = asyncio.Event()
|
||||||
|
gecancelt = asyncio.Event()
|
||||||
|
|
||||||
|
async def langsamer_agent(key, prompt, timeout, **kw):
|
||||||
|
gestartet.set()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
gecancelt.set()
|
||||||
|
raise
|
||||||
|
return agents.AgentErgebnis(0, "nie", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents, "run_agent", langsamer_agent)
|
||||||
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0) # Pfad: return await haupt
|
||||||
|
task = asyncio.ensure_future(llm._roher_call("k", "p", 10, ctx, "judge", "none"))
|
||||||
|
await gestartet.wait()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await asyncio.wait_for(gecancelt.wait(), timeout=2) # Waise wurde mitgecancelt
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_drainiert_statt_zu_canceln(monkeypatch):
|
||||||
|
"""ManuellePause in llm.alle: Laufende laufen zu Ende (Ergebnis zählt),
|
||||||
|
nichts wird gecancelt."""
|
||||||
|
import asyncio
|
||||||
|
import llm
|
||||||
|
fertig = {"langsam": False}
|
||||||
|
|
||||||
|
async def schnell_pausiert():
|
||||||
|
raise llm.ManuellePause("manuell pausiert")
|
||||||
|
|
||||||
|
async def langsam():
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
fertig["langsam"] = True
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(llm.ManuellePause):
|
||||||
|
await llm.alle([schnell_pausiert(), langsam()])
|
||||||
|
assert fertig["langsam"] is True # NICHT gecancelt — ausgelaufen
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_beendet_lauf_als_paused():
|
||||||
|
"""Pause-Flag → nächster Agent liefert „pausiert" → Lauf endet 'paused';
|
||||||
|
Start setzt normal fort (Flag wird aufgehoben)."""
|
||||||
|
import db
|
||||||
|
import pipeline
|
||||||
|
from conftest import topic_anlegen
|
||||||
|
topic = topic_anlegen("pausetest")
|
||||||
|
run_id = pipeline.lauf_starten(topic)
|
||||||
|
pipeline.lauf_pausieren(topic) # vor dem ersten Call — deterministisch
|
||||||
|
await pipeline._laeufe[topic]
|
||||||
|
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
|
||||||
|
assert run["status"] == "paused" and "pausiert" in run["grund"]
|
||||||
|
# Resume: Start hebt die Pause auf, Lauf läuft durch
|
||||||
|
pipeline.lauf_starten(topic)
|
||||||
|
await pipeline._laeufe[topic]
|
||||||
|
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_output_cap_ohne_neuversuch(monkeypatch):
|
||||||
|
"""stop=max_tokens ist deterministisch — sofort None statt 2 Neuversuche
|
||||||
|
(der Aufrufer halbiert den Chunk)."""
|
||||||
|
import agents
|
||||||
|
import db
|
||||||
|
import llm
|
||||||
|
from conftest import run_anlegen, topic_anlegen
|
||||||
|
topic = topic_anlegen("cap")
|
||||||
|
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||||
|
ctx.ebene = "inventar"
|
||||||
|
aufrufe = {"n": 0}
|
||||||
|
|
||||||
|
async def cap_agent(key, prompt, timeout, **kw):
|
||||||
|
aufrufe["n"] += 1
|
||||||
|
return agents.AgentErgebnis(1, "", "leere Antwort (stop=max_tokens)",
|
||||||
|
{"input": 100, "output": 32000})
|
||||||
|
monkeypatch.setattr(agents, "run_agent", cap_agent)
|
||||||
|
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0)
|
||||||
|
res = await llm.call(ctx, stage="extraktion", template="Atom-Extraktion",
|
||||||
|
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||||
|
assert res is None and aufrufe["n"] == 1 # genau EIN Versuch
|
||||||
|
ev = db.one("SELECT status, tok_out FROM events WHERE run_id=? AND stage='extraktion'",
|
||||||
|
(ctx.run_id,))
|
||||||
|
assert ev["status"] == "cap" and ev["tok_out"] == 32000
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ from conftest import run_anlegen, topic_anlegen
|
|||||||
def test_merge_kollision_und_kette():
|
def test_merge_kollision_und_kette():
|
||||||
topic = topic_anlegen("mergekante")
|
topic = topic_anlegen("mergekante")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
|
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
|
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
|
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
|
||||||
(topic, a, c))
|
(topic, a, c))
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
|
||||||
(topic, b, c))
|
(topic, b, c))
|
||||||
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
|
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
|
||||||
(topic, b, a))
|
(topic, b, a))
|
||||||
inventar._merge(topic, a, b)
|
inventar._merge(topic, a, b)
|
||||||
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
|
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
|
||||||
@@ -39,7 +39,7 @@ async def test_anker_rematch_ohne_llm(tmp_path):
|
|||||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||||
snapshot=str(snap), hash="h", status="atome")
|
snapshot=str(snap), hash="h", status="atome")
|
||||||
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
||||||
status="ohne_anker", braucht=db.j([]))
|
status="ohne_anker")
|
||||||
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
|
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
|
||||||
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
||||||
zitat="Der Satz steht hier drin.")
|
zitat="Der Satz steht hier drin.")
|
||||||
@@ -67,7 +67,7 @@ async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path):
|
|||||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||||
snapshot=str(snap), hash="h", status="extrahiert", atome_stand="")
|
snapshot=str(snap), hash="h", status="extrahiert", atome_stand="")
|
||||||
a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
|
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "inventar"
|
ctx.ebene = "inventar"
|
||||||
@@ -94,7 +94,7 @@ async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch):
|
|||||||
|
|
||||||
def neu_atom():
|
def neu_atom():
|
||||||
return db.insert("atome", topic=topic, titel="X", typ="begriff",
|
return db.insert("atome", topic=topic, titel="X", typ="begriff",
|
||||||
definition="d", status="neu", braucht=db.j([]))
|
definition="d", status="neu")
|
||||||
|
|
||||||
def panel_mit(votes):
|
def panel_mit(votes):
|
||||||
async def _p(ctx, groesse, **kw):
|
async def _p(ctx, groesse, **kw):
|
||||||
@@ -127,7 +127,7 @@ async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path):
|
|||||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||||
snapshot=str(snap), hash="h", status="atome")
|
snapshot=str(snap), hash="h", status="atome")
|
||||||
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
||||||
status="ohne_anker", braucht=db.j([]))
|
status="ohne_anker")
|
||||||
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
||||||
zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.")
|
zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.")
|
||||||
monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {})
|
monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {})
|
||||||
@@ -174,14 +174,20 @@ async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
|
|||||||
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||||
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
|
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
|
||||||
" der Größe höchstens k enthält.",
|
" der Größe höchstens k enthält.",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||||
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
|
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
|
||||||
" abdeckt und maximal k Elemente hat.",
|
" abdeckt und maximal k Elemente hat.",
|
||||||
status="neu", braucht=db.j([]))
|
status="neu")
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "inventar"
|
ctx.ebene = "inventar"
|
||||||
await inventar._judge_dedup(ctx)
|
await inventar._judge_dedup(ctx)
|
||||||
stati = {r["id"]: r["status"] for r in db.query(
|
stati = {r["id"]: r["status"] for r in db.query(
|
||||||
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
|
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
|
||||||
assert sorted(stati.values()) == ["gemerged", "neu"]
|
assert sorted(stati.values()) == ["gemerged", "neu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_titel_kaputt_flaggt_latex():
|
||||||
|
assert inventar._titel_kaputt("Variablen in $\\alpha_k$")
|
||||||
|
assert inventar._titel_kaputt("Der \\textsc{VC}-Beweis")
|
||||||
|
assert not inventar._titel_kaputt("Vertex Cover und Entscheidungsvariante")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from conftest import topic_anlegen
|
|||||||
def _setup(topic):
|
def _setup(topic):
|
||||||
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
|
||||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||||
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
|
status="neu", baustein_id=b, ord=0)
|
||||||
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
|
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
|
||||||
inhalt=db.j({"frage": "F1", "antwort": "A1"}))
|
inhalt=db.j({"frage": "F1", "antwort": "A1"}))
|
||||||
db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat", # unverifiziert
|
db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat", # unverifiziert
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
|
"""Struktur-Ebene: Band-Schnitt, Judge-Ordnung mit Quellpositions-Prior,
|
||||||
|
Kapitel — mit synthetischen Atomen."""
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import inventar
|
import fake_agents
|
||||||
import llm
|
import llm
|
||||||
import struktur
|
import struktur
|
||||||
from conftest import run_anlegen, topic_anlegen
|
from conftest import run_anlegen, topic_anlegen
|
||||||
@@ -10,7 +11,7 @@ from conftest import run_anlegen, topic_anlegen
|
|||||||
def _atom(topic, titel, ziel_id=None, soll_id=1):
|
def _atom(topic, titel, ziel_id=None, soll_id=1):
|
||||||
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
||||||
definition=f"Definition {titel}", status="neu",
|
definition=f"Definition {titel}", status="neu",
|
||||||
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
|
soll_id=soll_id, ziel_id=ziel_id)
|
||||||
|
|
||||||
|
|
||||||
def _baustein_groessen(topic):
|
def _baustein_groessen(topic):
|
||||||
@@ -29,7 +30,7 @@ async def test_band_split_43_atome():
|
|||||||
_atom(topic, f"A{i}", ziel, soll)
|
_atom(topic, f"A{i}", ziel, soll)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "struktur"
|
ctx.ebene = "struktur"
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
groessen = _baustein_groessen(topic)
|
groessen = _baustein_groessen(topic)
|
||||||
assert sum(groessen) == 43
|
assert sum(groessen) == 43
|
||||||
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
|
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
|
||||||
@@ -51,7 +52,7 @@ async def test_band_messen_konsistent_mit_schnitt():
|
|||||||
_atom(topic, f"G{i}", z2, soll)
|
_atom(topic, f"G{i}", z2, soll)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "struktur"
|
ctx.ebene = "struktur"
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
assert all(4 <= n <= 8 for n in _baustein_groessen(topic))
|
assert all(4 <= n <= 8 for n in _baustein_groessen(topic))
|
||||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||||
|
|
||||||
@@ -65,52 +66,10 @@ async def test_einsame_kleingruppe_kein_band():
|
|||||||
_atom(topic, f"A{i}", ziel, soll)
|
_atom(topic, f"A{i}", ziel, soll)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "struktur"
|
ctx.ebene = "struktur"
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||||
|
|
||||||
|
|
||||||
def test_topo_ordnung():
|
|
||||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
|
||||||
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
|
||||||
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_topo_fremde_kanten_bleiben_draussen():
|
|
||||||
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
|
|
||||||
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
|
|
||||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
|
||||||
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
|
|
||||||
assert out == [1, 2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_zyklus_finden():
|
|
||||||
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
|
|
||||||
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_zyklen_brechen_und_schneiden():
|
|
||||||
topic = topic_anlegen()
|
|
||||||
run = run_anlegen(topic)
|
|
||||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
|
||||||
a = _atom(topic, "A", ziel, soll)
|
|
||||||
b = _atom(topic, "B", ziel, soll)
|
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
|
||||||
(topic, a, b))
|
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
|
||||||
(topic, b, a))
|
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
|
||||||
ctx.ebene = "struktur"
|
|
||||||
await struktur._zyklen_brechen(ctx)
|
|
||||||
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
|
|
||||||
(topic,))
|
|
||||||
assert len(aktiv) == 1 # genau eine Kante gebrochen
|
|
||||||
|
|
||||||
struktur._bausteine_schneiden(ctx)
|
|
||||||
atome = struktur._atome(topic)
|
|
||||||
assert all(x["baustein_id"] for x in atome)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_band_split():
|
async def test_band_split():
|
||||||
topic = topic_anlegen("band")
|
topic = topic_anlegen("band")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
@@ -119,34 +78,31 @@ async def test_band_split():
|
|||||||
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
|
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
|
||||||
_atom(topic, f"A{i}", ziel, soll)
|
_atom(topic, f"A{i}", ziel, soll)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||||
assert len(bausteine) >= 3
|
assert len(bausteine) >= 3
|
||||||
for bs in bausteine:
|
for bs in bausteine:
|
||||||
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
|
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
|
||||||
assert 4 <= n <= 8
|
assert 4 <= n <= 8
|
||||||
befunde = struktur.messen(ctx)
|
befunde = struktur.messen(ctx)
|
||||||
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
|
assert not [x for x in befunde if x["art"] in ("band", "partition")]
|
||||||
|
|
||||||
|
|
||||||
async def test_merge_ohne_soll_schranke_und_kapitel():
|
async def test_merge_ohne_soll_schranke_und_kapitel():
|
||||||
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
|
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
|
||||||
# Kapitel entstehen danach als kontiguierliche Segmente
|
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente
|
||||||
topic = topic_anlegen("kapitel")
|
topic = topic_anlegen("kapitel")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
||||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
||||||
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
|
_atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
|
||||||
b0 = _atom(topic, "B0", z2, s2)
|
for i in (0, 1, 2, 3):
|
||||||
for i in (1, 2, 3):
|
|
||||||
_atom(topic, f"B{i}", z2, s2)
|
_atom(topic, f"B{i}", z2, s2)
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
|
||||||
(topic, a, b0))
|
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "struktur"
|
ctx.ebene = "struktur"
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
atome = struktur._atome(topic)
|
atome = struktur._atome(topic)
|
||||||
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
|
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
|
||||||
await struktur._kapitel_bilden(ctx)
|
await struktur._kapitel_bilden(ctx)
|
||||||
@@ -155,8 +111,79 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
|
|||||||
assert struktur.messen(ctx) == []
|
assert struktur.messen(ctx) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ordnung_judge_permutation(monkeypatch):
|
||||||
|
"""Der Ordnungs-Judge bestimmt die Lehr-Reihenfolge; Prior ist die
|
||||||
|
Quellposition (Eingabe-Nummerierung)."""
|
||||||
|
topic = topic_anlegen("ordjudge")
|
||||||
|
run = run_anlegen(topic)
|
||||||
|
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||||
|
ziele = [db.insert("lernziele", topic=topic, text=f"Kann Z{i}", soll_id=soll,
|
||||||
|
status="aktiv") for i in range(2)]
|
||||||
|
q = db.insert("quellen", topic=topic, art="datei", titel="s.txt", snapshot="s",
|
||||||
|
rolle="stoff", status="atome")
|
||||||
|
for zi, ziel in enumerate(ziele):
|
||||||
|
for i in range(4):
|
||||||
|
a = _atom(topic, f"Z{zi}A{i}", ziel, soll)
|
||||||
|
db.insert("anker", atom_id=a, quelle_id=q, start=zi * 1000 + i, ende=0,
|
||||||
|
zitat="x")
|
||||||
|
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung",
|
||||||
|
lambda p: list(reversed(fake_agents._baustein_ordnung(p))))
|
||||||
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
|
ctx.ebene = "struktur"
|
||||||
|
await struktur._bausteine_schneiden(ctx)
|
||||||
|
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||||
|
# Prior wäre Z0 vor Z1 — der Judge hat umgedreht, und das gilt
|
||||||
|
assert [b["ziel_id"] for b in bausteine] == [ziele[1], ziele[0]]
|
||||||
|
assert all(b["ordnung"] == "judge" for b in bausteine)
|
||||||
|
assert "ordnung_fallback" not in {x["art"] for x in struktur.messen(ctx)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ordnung_retry_und_fallback(monkeypatch):
|
||||||
|
topic = topic_anlegen("ordfall")
|
||||||
|
run = run_anlegen(topic)
|
||||||
|
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||||
|
z1 = db.insert("lernziele", topic=topic, text="Z1", soll_id=soll, status="aktiv")
|
||||||
|
z2 = db.insert("lernziele", topic=topic, text="Z2", soll_id=soll, status="aktiv")
|
||||||
|
for ziel in (z1, z2):
|
||||||
|
for i in range(4):
|
||||||
|
_atom(topic, f"{ziel}A{i}", ziel, soll)
|
||||||
|
aufrufe = {"n": 0}
|
||||||
|
|
||||||
|
def judge(prompt):
|
||||||
|
aufrufe["n"] += 1
|
||||||
|
if aufrufe["n"] == 1:
|
||||||
|
return [1, 1] # keine Permutation
|
||||||
|
return [2, 1]
|
||||||
|
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", judge)
|
||||||
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
|
ctx.ebene = "struktur"
|
||||||
|
await struktur._bausteine_schneiden(ctx)
|
||||||
|
assert aufrufe["n"] == 2 # Retry hat gegriffen
|
||||||
|
assert all(b["ordnung"] == "judge" for b in
|
||||||
|
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)))
|
||||||
|
|
||||||
|
# beide Versuche ungültig → Prior-Ordnung + Befund
|
||||||
|
monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", lambda p: ["x"])
|
||||||
|
await struktur._bausteine_schneiden(ctx)
|
||||||
|
assert all(b["ordnung"] == "fallback" for b in
|
||||||
|
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)))
|
||||||
|
assert "ordnung_fallback" in {x["art"] for x in struktur.messen(ctx)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_baustein_rang_median_gegen_merge_gift():
|
||||||
|
"""ETH-Muster: EIN importiertes Atom vom Quellanfang darf den Baustein nicht
|
||||||
|
nach vorn ziehen — der Median liegt bei den echten Mitgliedern."""
|
||||||
|
bausteine = [{"id": 1}, {"id": 2}]
|
||||||
|
atome = [{"id": 10, "baustein_id": 1}, {"id": 11, "baustein_id": 1},
|
||||||
|
{"id": 12, "baustein_id": 1}, # Merge-Import mit Rang vom Dateianfang
|
||||||
|
{"id": 20, "baustein_id": 2}, {"id": 21, "baustein_id": 2}]
|
||||||
|
rang = {10: (1, 47000), 11: (1, 48000), 12: (1, 464),
|
||||||
|
20: (1, 5000), 21: (1, 6000)}
|
||||||
|
b_rang = struktur._baustein_rang(bausteine, atome, rang)
|
||||||
|
assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
|
||||||
|
|
||||||
|
|
||||||
async def test_kapitel_retry_und_fallback(monkeypatch):
|
async def test_kapitel_retry_und_fallback(monkeypatch):
|
||||||
import fake_agents
|
|
||||||
topic = topic_anlegen("kapfall")
|
topic = topic_anlegen("kapfall")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
@@ -185,14 +212,13 @@ async def test_kapitel_retry_und_fallback(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
async def test_level_kalibrierung(monkeypatch):
|
async def test_level_kalibrierung(monkeypatch):
|
||||||
import fake_agents
|
|
||||||
topic = topic_anlegen("level")
|
topic = topic_anlegen("level")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||||
a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d",
|
a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d",
|
||||||
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
|
level="M", status="neu", ziel_id=ziel)
|
||||||
a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d",
|
a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d",
|
||||||
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
|
level="M", status="neu", ziel_id=ziel)
|
||||||
|
|
||||||
def judge(prompt):
|
def judge(prompt):
|
||||||
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
|
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
|
||||||
@@ -205,50 +231,6 @@ async def test_level_kalibrierung(monkeypatch):
|
|||||||
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
|
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
|
||||||
|
|
||||||
|
|
||||||
def test_level_konflikt_absenkung():
|
|
||||||
# v(E) braucht z(S), z braucht w(M) → Kaskade senkt z UND w auf E
|
|
||||||
topic = topic_anlegen("konflikt")
|
|
||||||
run = run_anlegen(topic)
|
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
|
||||||
def atom(titel, level):
|
|
||||||
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
|
||||||
definition="d", level=level, status="neu", ziel_id=ziel,
|
|
||||||
braucht=db.j([]))
|
|
||||||
v, z, w = atom("V", "E"), atom("Z", "S"), atom("W", "M")
|
|
||||||
for von, zu in ((v, z), (z, w)):
|
|
||||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art)"
|
|
||||||
" VALUES(?,?,?,'braucht')", (topic, von, zu))
|
|
||||||
struktur._level_konflikte_loesen(topic)
|
|
||||||
assert db.one("SELECT level FROM atome WHERE id=?", (z,))["level"] == "E"
|
|
||||||
assert db.one("SELECT level FROM atome WHERE id=?", (w,))["level"] == "E"
|
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
|
||||||
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_braucht_fallback_kompositum():
|
|
||||||
# braucht-Titel "Approximationsalgorithmus" (Kompositum) trifft weder norm noch
|
|
||||||
# titel_kern das Atom "Approximativer Algorithmus" (Phrase). Der Fallback-Judge
|
|
||||||
# legt die Kante; _level_konflikte_loesen senkt die Voraussetzung von M auf E.
|
|
||||||
topic = topic_anlegen("brfall")
|
|
||||||
run = run_anlegen(topic)
|
|
||||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
|
||||||
a = db.insert("atome", topic=topic, titel="Absolute Güte", typ="begriff",
|
|
||||||
definition="d", level="E", status="neu", ziel_id=ziel,
|
|
||||||
braucht=db.j(["Approximationsalgorithmus"]))
|
|
||||||
b = db.insert("atome", topic=topic, titel="Approximativer Algorithmus", typ="begriff",
|
|
||||||
definition="d", level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
|
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
|
||||||
ctx.ebene = "struktur"
|
|
||||||
inventar._kanten_aufloesen(topic) # deterministisch: verfehlt das Kompositum
|
|
||||||
assert db.query("SELECT * FROM kanten WHERE topic=? AND von_atom=?", (topic, a)) == []
|
|
||||||
await inventar._braucht_fallback(ctx)
|
|
||||||
kante = db.one("SELECT * FROM kanten WHERE topic=? AND von_atom=? AND zu_atom=?",
|
|
||||||
(topic, a, b))
|
|
||||||
assert kante and kante["art"] == "braucht"
|
|
||||||
struktur._level_konflikte_loesen(topic)
|
|
||||||
assert db.one("SELECT level FROM atome WHERE id=?", (b,))["level"] == "E"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_level_split_ordnung_kapitel():
|
async def test_level_split_ordnung_kapitel():
|
||||||
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
|
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
|
||||||
# Kapitel je Durchgang, kein level_mix/kapitel_level
|
# Kapitel je Durchgang, kein level_mix/kapitel_level
|
||||||
@@ -258,12 +240,12 @@ async def test_level_split_ordnung_kapitel():
|
|||||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||||
level="E", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
|
level="E", status="neu", soll_id=soll, ziel_id=ziel)
|
||||||
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
||||||
level="M", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
|
level="M", status="neu", soll_id=soll, ziel_id=ziel)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
ctx.ebene = "struktur"
|
ctx.ebene = "struktur"
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||||
assert [b["level"] for b in bausteine] == ["E", "M"]
|
assert [b["level"] for b in bausteine] == ["E", "M"]
|
||||||
await struktur._kapitel_bilden(ctx)
|
await struktur._kapitel_bilden(ctx)
|
||||||
@@ -273,7 +255,7 @@ async def test_level_split_ordnung_kapitel():
|
|||||||
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
|
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
|
||||||
|
|
||||||
|
|
||||||
def test_kein_merge_ueber_level():
|
async def test_kein_merge_ueber_level():
|
||||||
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
|
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
|
||||||
topic = topic_anlegen("levelmerge")
|
topic = topic_anlegen("levelmerge")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
@@ -282,11 +264,11 @@ def test_kein_merge_ueber_level():
|
|||||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
|
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||||
level="E", status="neu", soll_id=soll, ziel_id=z1, braucht=db.j([]))
|
level="E", status="neu", soll_id=soll, ziel_id=z1)
|
||||||
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
||||||
level="M", status="neu", soll_id=soll, ziel_id=z2, braucht=db.j([]))
|
level="M", status="neu", soll_id=soll, ziel_id=z2)
|
||||||
ctx = llm.Kontext(run, topic, "minimax")
|
ctx = llm.Kontext(run, topic, "minimax")
|
||||||
struktur._bausteine_schneiden(ctx)
|
await struktur._bausteine_schneiden(ctx)
|
||||||
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
||||||
levels = {a["level"] for a in struktur._atome(topic)
|
levels = {a["level"] for a in struktur._atome(topic)
|
||||||
if a["baustein_id"] == b["id"]}
|
if a["baustein_id"] == b["id"]}
|
||||||
|
|||||||
@@ -63,11 +63,18 @@ async def test_import_remappt_auftrag_atom_ids():
|
|||||||
db.insert("auftraege", baustein_id=b["id"], art="falsch",
|
db.insert("auftraege", baustein_id=b["id"], art="falsch",
|
||||||
detail=f"Atom {a['id']} widerspricht dem Text")
|
detail=f"Atom {a['id']} widerspricht dem Text")
|
||||||
d = json.loads(json.dumps(transfer.export(topic)))
|
d = json.loads(json.dumps(transfer.export(topic)))
|
||||||
# Alt-Export simulieren: befunde-Freitext an einer Section
|
# Alt-Export simulieren: befunde-Freitext an einer Section, braucht-Spalte
|
||||||
|
# an Atomen, braucht-Kante — alles muss crashfrei toleriert werden
|
||||||
d["sections"][0]["befunde"] = db.j([
|
d["sections"][0]["befunde"] = db.j([
|
||||||
f"KRITISCH (luecke): Atom {a['id']} fehlt inhaltlich",
|
f"KRITISCH (luecke): Atom {a['id']} fehlt inhaltlich",
|
||||||
"Langtext, Absatz 3 hat 120 Wörter (Regel: 40–90): teilen."]) # → verwerfen
|
"Langtext, Absatz 3 hat 120 Wörter (Regel: 40–90): teilen."]) # → verwerfen
|
||||||
|
for z in d["atome"]:
|
||||||
|
z["braucht"] = "[]"
|
||||||
|
d["kanten"].append({"id": 999, "topic": topic, "von_atom": d["atome"][0]["id"],
|
||||||
|
"zu_atom": d["atome"][-1]["id"], "art": "braucht",
|
||||||
|
"status": "aktiv"})
|
||||||
transfer.importieren(d)
|
transfer.importieren(d)
|
||||||
|
assert db.query("SELECT * FROM kanten WHERE art='braucht'") == []
|
||||||
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
|
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
|
||||||
zeilen = db.query("SELECT au.* FROM auftraege au JOIN bausteine b"
|
zeilen = db.query("SELECT au.* FROM auftraege au JOIN bausteine b"
|
||||||
" ON b.id=au.baustein_id WHERE b.topic=?", (topic,))
|
" ON b.id=au.baustein_id WHERE b.topic=?", (topic,))
|
||||||
|
|||||||
Reference in New Issue
Block a user