update
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
storage
|
||||||
|
topics
|
||||||
|
.claude-data
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
.pytest_cache
|
||||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Stage 1: Frontend bauen
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime — bewusst schlanker als beim alten creator:
|
||||||
|
# kein Playwright/Chromium, kein Tesseract (creator2 braucht beides nicht).
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
poppler-utils \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& npm install -g @anthropic-ai/claude-code opencode-ai \
|
||||||
|
&& pip install --no-cache-dir uv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd -m -u 1000 app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
# torch als CPU-Build (~190 MB) — sonst zieht sentence-transformers die
|
||||||
|
# CUDA-Variante (~2,5 GB).
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
|
||||||
|
&& pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=app:app backend/ /app/backend/
|
||||||
|
COPY --chown=app:app templates/ /app/templates/
|
||||||
|
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
||||||
|
COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json
|
||||||
|
|
||||||
|
RUN mkdir -p /app/storage /app/topics && chown -R app:app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
# Embedding-Modell ins Image backen — sonst lädt jeder Container-Neustart
|
||||||
|
# ~120 MB von Hugging Face (Dedup-Kandidaten, embedding.py)
|
||||||
|
RUN python3 -c "from sentence_transformers import SentenceTransformer; \
|
||||||
|
SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')"
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
38
Makefile
38
Makefile
@@ -25,3 +25,41 @@ test-e2e:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
# ── Server-Deployment (Muster vom alten creator; Traefik läuft dort schon) ────
|
||||||
|
SERVER = root@178.104.67.87
|
||||||
|
REMOTE = /var/www/creator2
|
||||||
|
|
||||||
|
# Code (+ .env) auf den Server spiegeln und Container neu bauen.
|
||||||
|
# storage/topics sind ausgenommen — Daten leben nur auf der jeweiligen Seite.
|
||||||
|
deploy:
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude .git --exclude node_modules --exclude frontend/node_modules \
|
||||||
|
--exclude frontend/dist --exclude storage --exclude topics \
|
||||||
|
--exclude .claude-data --exclude __pycache__ --exclude .pytest_cache \
|
||||||
|
./ $(SERVER):$(REMOTE)/
|
||||||
|
ssh $(SERVER) 'cd $(REMOTE) && docker compose up -d --build'
|
||||||
|
@echo "Deploy fertig: https://creator2.marha.de"
|
||||||
|
|
||||||
|
# Remote-Daten holen (Remote wird für den DB-Snapshot gestoppt und neu gestartet).
|
||||||
|
sync: stop
|
||||||
|
@rm -f storage/creator2.db-shm storage/creator2.db-wal
|
||||||
|
ssh $(SERVER) 'cd $(REMOTE) && docker compose down'
|
||||||
|
rsync -avz --progress $(SERVER):$(REMOTE)/storage/creator2.db storage/
|
||||||
|
-rsync -avz --progress $(SERVER):$(REMOTE)/storage/creator2.db-wal storage/
|
||||||
|
rsync -avz --progress --delete $(SERVER):$(REMOTE)/storage/korpus/ storage/korpus/
|
||||||
|
rsync -avz --progress --delete $(SERVER):$(REMOTE)/topics/ topics/
|
||||||
|
ssh $(SERVER) 'cd $(REMOTE) && docker compose up -d'
|
||||||
|
@echo "Sync abgeschlossen — Remote läuft wieder."
|
||||||
|
|
||||||
|
# Lokalen Stand auf den Server schieben (überschreibt Remote-Daten!).
|
||||||
|
sync-reverse: stop
|
||||||
|
@[ -f storage/creator2.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
|
||||||
|
ssh $(SERVER) 'cd $(REMOTE) && docker compose down'
|
||||||
|
ssh $(SERVER) 'mkdir -p $(REMOTE)/storage $(REMOTE)/topics && rm -f $(REMOTE)/storage/creator2.db-shm $(REMOTE)/storage/creator2.db-wal'
|
||||||
|
rsync -avz --progress storage/creator2.db $(SERVER):$(REMOTE)/storage/
|
||||||
|
-rsync -avz --progress storage/creator2.db-wal $(SERVER):$(REMOTE)/storage/
|
||||||
|
rsync -avz --progress --delete storage/korpus/ $(SERVER):$(REMOTE)/storage/korpus/
|
||||||
|
rsync -avz --progress --delete topics/ $(SERVER):$(REMOTE)/topics/
|
||||||
|
ssh $(SERVER) 'cd $(REMOTE) && docker compose up -d'
|
||||||
|
@echo "Reverse-Sync abgeschlossen — Remote läuft wieder."
|
||||||
|
|||||||
@@ -104,7 +104,31 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
|
|||||||
return [a["id"] for a in atome if a["id"] not in da]
|
return [a["id"] for a in atome if a["id"] not in da]
|
||||||
|
|
||||||
|
|
||||||
def _det_auftraege(topic: str, b: dict, lang: str) -> list[str]:
|
def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||||
|
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
||||||
|
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
||||||
|
Lang-Fassungen betroffen)."""
|
||||||
|
auftraege = []
|
||||||
|
for nr, absatz in enumerate(text.split("\n\n"), 1):
|
||||||
|
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
|
||||||
|
auftraege.append(f"{wo}, Absatz {nr}: ungerade Anzahl $-Zeichen — jede"
|
||||||
|
f" Formel braucht öffnendes UND schließendes $.")
|
||||||
|
ohne = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", text)
|
||||||
|
nackt = sorted(set(re.findall(
|
||||||
|
r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists"
|
||||||
|
r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne)))
|
||||||
|
if nackt: # KaTeX rendert nur innerhalb von $…$
|
||||||
|
auftraege.append(f"{wo}: LaTeX ohne $-Delimiter ({', '.join(nackt[:5])}…):"
|
||||||
|
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
|
||||||
|
reste = sorted(set(re.findall(
|
||||||
|
r"\w+_\{[^}]*\}|\w+\^\{[^}]*\}|\w+\^\([^)]*\)|\b\w+_[a-z0-9]\b", ohne)))
|
||||||
|
if reste:
|
||||||
|
auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):"
|
||||||
|
f" auch Indizes/Potenzen gehören in $…$ (x_i → $x_i$).")
|
||||||
|
return auftraege
|
||||||
|
|
||||||
|
|
||||||
|
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]:
|
||||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
||||||
atome = _atome_von(b["id"])
|
atome = _atome_von(b["id"])
|
||||||
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
||||||
@@ -126,18 +150,9 @@ def _det_auftraege(topic: str, b: dict, lang: str) -> list[str]:
|
|||||||
if "6=" in lang:
|
if "6=" in lang:
|
||||||
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
||||||
" durch $\\neq$ ersetzen.")
|
" durch $\\neq$ ersetzen.")
|
||||||
for nr, absatz in enumerate(lang.split("\n\n"), 1):
|
auftraege += _mathe_auftraege(lang, "Langtext")
|
||||||
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
|
if kompakt:
|
||||||
auftraege.append(f"Absatz {nr}: ungerade Anzahl $-Zeichen — jede Formel"
|
auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung")
|
||||||
f" braucht öffnendes UND schließendes $ (Beispiel-Fehler:"
|
|
||||||
f" „$[.“ statt „$[$.“).")
|
|
||||||
ohne_mathe = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", lang)
|
|
||||||
nackt = sorted(set(re.findall(
|
|
||||||
r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists"
|
|
||||||
r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne_mathe)))
|
|
||||||
if nackt: # KaTeX rendert nur innerhalb von $…$ — nackte Befehle bleiben Rohtext
|
|
||||||
auftraege.append(f"LaTeX ohne $-Delimiter im Text ({', '.join(nackt[:5])}…):"
|
|
||||||
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
|
|
||||||
return auftraege
|
return auftraege
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +183,7 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
|
|||||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||||
atome = _atome_von(b["id"])
|
atome = _atome_von(b["id"])
|
||||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||||
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"])
|
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
|
||||||
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
||||||
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
|
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
|
||||||
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
|
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ def state(topic: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/topics/{topic}/guide")
|
@app.get("/api/topics/{topic}/guide")
|
||||||
def guide_holen(topic: str):
|
def guide_holen(topic: str):
|
||||||
# kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown
|
# kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown
|
||||||
|
|||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Runtime-Abhängigkeiten (torch kommt im Dockerfile als CPU-Build davor)
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
httpx
|
||||||
|
ftfy
|
||||||
|
fuzzysearch
|
||||||
|
sentence-transformers
|
||||||
125
dev-ops/opencode.json
Normal file
125
dev-ops/opencode.json
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"minimax": {
|
||||||
|
"options": {
|
||||||
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"name": "MiniMax M3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimax-kalt": {
|
||||||
|
"npm": "@ai-sdk/anthropic",
|
||||||
|
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||||
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"name": "MiniMax M3 (kalt)",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.2,
|
||||||
|
"thinking": { "type": "disabled" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MiniMax-M2.7-highspeed": {
|
||||||
|
"name": "MiniMax M2.7 highspeed (kalt)",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Ollama (lokal)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://localhost:11434/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"qwen3.6:27b": {
|
||||||
|
"name": "Qwen3.6 27B"
|
||||||
|
},
|
||||||
|
"qwen3.5:9b": {
|
||||||
|
"name": "Qwen3.5 9B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"minimax-search": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["uvx", "minimax-coding-plan-mcp"],
|
||||||
|
"environment": {
|
||||||
|
"MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}",
|
||||||
|
"MINIMAX_API_HOST": "https://api.minimax.io"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "mcp-searxng"],
|
||||||
|
"environment": {
|
||||||
|
"SEARXNG_URL": "http://localhost:8888"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"full": {
|
||||||
|
"description": "Alle Tools: Dateien, Bash, Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "allow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"description": "Dateien lesen/schreiben + Bash, keine Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"readonly": {
|
||||||
|
"description": "Nur Dateien lesen",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"description": "Reine Textantwort, keine Tools",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"read": false,
|
||||||
|
"glob": false,
|
||||||
|
"grep": false,
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
creator2:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: creator2
|
||||||
|
restart: unless-stopped
|
||||||
|
# komplette .env durchreichen (Lektion creator: Einzel-Vars ließen
|
||||||
|
# ROLE_*/MAX_CONCURRENT_* still weg)
|
||||||
|
env_file: .env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
- ./topics:/app/topics
|
||||||
|
- ./.claude-data:/home/app/.claude
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.creator2app.rule=Host(`creator2.marha.de`)"
|
||||||
|
- "traefik.http.routers.creator2app.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.creator2app.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.creator2app.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: true
|
||||||
@@ -38,7 +38,9 @@ Regeln (hart):
|
|||||||
4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$`
|
4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$`
|
||||||
(z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle
|
(z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle
|
||||||
ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
|
ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
|
||||||
IMMER umschließende `$…$`) — ohne Delimiter rendert nichts.
|
IMMER umschließende `$…$`) — ohne Delimiter rendert nichts. Das gilt für BEIDE
|
||||||
|
Fassungen, auch die Stichpunkte in „kompakt": Indizes und Potenzen nie als
|
||||||
|
Klartext (`x_i`, `2^(n)`), sondern `$x_i$`, `$2^n$`.
|
||||||
5. LÄNGE: „lang" hat {min_woerter}–{max_woerter} Wörter. Die Obergrenze ist ein HARTES
|
5. LÄNGE: „lang" hat {min_woerter}–{max_woerter} Wörter. Die Obergrenze ist ein HARTES
|
||||||
Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel
|
Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel
|
||||||
ein, kein Fülltext, keine Wiederholungen.
|
ein, kein Fülltext, keine Wiederholungen.
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ def test_det_auftraege_blockquote_und_artefakt():
|
|||||||
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
|
lang = (f"<!-- atom: {a} | A | E -->\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$.")
|
||||||
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang)
|
kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext."
|
||||||
|
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt)
|
||||||
text = " ".join(auftraege)
|
text = " ".join(auftraege)
|
||||||
assert "Blockquote" in text and "6=" in text
|
assert "Blockquote" in text and "6=" in text
|
||||||
assert "ungerade Anzahl $-Zeichen" in text
|
assert "ungerade Anzahl $-Zeichen" in text
|
||||||
|
assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen
|
||||||
|
|||||||
Reference in New Issue
Block a user