This commit is contained in:
team3
2026-07-10 16:29:35 +02:00
parent 718c18523d
commit a50fb701b5
8 changed files with 115 additions and 41 deletions

View File

@@ -29,6 +29,8 @@ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/wh
&& pip install --no-cache-dir -r /app/backend/requirements.txt
COPY --chown=app:app backend/ /app/backend/
# katex fürs Parse-Gate (backend/katex_check.mjs prüft Formeln vor dem Speichern)
RUN cd /app/backend && npm install --no-audit --no-fund katex && chown -R app:app node_modules
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

View File

@@ -1,4 +1,4 @@
.PHONY: install dev stop test test-e2e build
.PHONY: install dev stop test test-e2e build prod
install:
pip install --break-system-packages fastapi "uvicorn[standard]" httpx pytest pytest-asyncio ftfy fuzzysearch
@@ -26,40 +26,7 @@ test-e2e:
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."
# ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
prod:
docker compose up -d --build
@echo "creator2 läuft: https://creator2.marha.de"

View File

@@ -7,8 +7,12 @@ setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage,
Lektion 52); deterministische Checks laufen davor im Code."""
import asyncio
import json
import logging
import os
import re
import subprocess
from pathlib import Path
import db
import llm
@@ -17,6 +21,33 @@ from config import SECTION_WOERTER_PRO_ATOM
log = logging.getLogger("creator2.guide")
_KATEX_SKRIPT = Path(__file__).resolve().parent / "katex_check.mjs"
_KATEX_BASE = Path(__file__).resolve().parent.parent / "frontend"
_katex_gewarnt = False
def _katex_fehler(formeln: list[dict]) -> list[str]:
"""Parse-Gate: Formeln durch DENSELBEN Renderer wie das Frontend jagen.
Regex-Checks jagen eine offene Menge (heute \\textsc, morgen der nächste
unbekannte Befehl) — das Gate schließt die Klasse. Fake-Modus überspringt
(deterministisch + schnell); Unit-Test ruft direkt."""
global _katex_gewarnt
if not formeln or os.getenv("CREATOR_FAKE_AGENTS"):
return []
try:
res = subprocess.run(
["node", str(_KATEX_SKRIPT)], input=json.dumps(formeln),
capture_output=True, text=True, timeout=30,
env={**os.environ, "KATEX_BASE": str(_KATEX_BASE)})
if res.returncode:
raise RuntimeError(res.stderr.strip()[:200])
return json.loads(res.stdout)
except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip)
if not _katex_gewarnt:
_katex_gewarnt = True
log.warning("KaTeX-Gate nicht verfügbar (%s) — Formel-Validierung fällt aus", e)
return []
EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
@@ -125,6 +156,24 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
if reste:
auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):"
f" auch Indizes/Potenzen gehören in $…$ (x_i → $x_i$).")
betraege = sorted(set(re.findall(r"\|[A-Za-z][^|\n]{0,8}\|\*?", ohne)))
if betraege: # |C*| im Klartext: Markdown deutet * als Kursiv-Start
auftraege.append(f"{wo}: Beträge im Klartext ({', '.join(betraege[:4])}…):"
f" in $…$ setzen ($|C^*|$) — sonst kollidiert * mit Markdown.")
for z in ohne.splitlines(): # einzelnes * = Kursiv-Unfall (nur **fett** erlaubt)
z = re.sub(r"^\s*[*-]\s", "", z)
if re.search(r"(?<!\*)\*(?!\*)", z):
auftraege.append(f"{wo}: einzelnes „*“ im Fließtext startet ungewollt"
f" Kursivschrift — Sterne nur als **fett** oder in $…$.")
break
if re.search(r"\\n(?![a-zA-Z])", text): # \neq etc. bleiben unberührt
auftraege.append(f"{wo}: literales „\\n“ im Text — durch echten"
f" Zeilenumbruch bzw. Leerzeile ersetzen.")
formeln = ([{"tex": m, "display": True} for m in re.findall(r"\$\$([\s\S]+?)\$\$", text)]
+ [{"tex": m, "display": False} for m in re.findall(r"\$([^$\n]+?)\$", text)])
for f in _katex_fehler(formeln):
auftraege.append(f"{wo}: Formel rendert nicht — {f}. In gültiges KaTeX"
f" umschreiben (Standardbefehle, kein LaTeX-Textsatz).")
return auftraege

28
backend/katex_check.mjs Normal file
View File

@@ -0,0 +1,28 @@
// KaTeX-Parse-Gate: prüft Formeln mit DEMSELBEN Renderer wie das Frontend.
// stdin: JSON [{tex, display}] → stdout: JSON [fehlermeldung, …]
// Ersetzt die Regex-Jagd auf einzelne Befehle: was hier durchgeht, rendert auch.
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
let katex
try {
katex = createRequire(import.meta.url)('katex') // Container: backend/node_modules
} catch {
const basis = process.env.KATEX_BASE // Dev: frontend/node_modules
katex = createRequire(basis.endsWith('/') ? basis : basis + '/')('katex')
}
// gleiche Makros wie frontend/src/markdown.js — das Gate darf nicht strenger
// sein als der Renderer
const macros = { '\\textsc': '\\text{#1}' }
const formeln = JSON.parse(readFileSync(0, 'utf8'))
const fehler = []
for (const f of formeln) {
try {
katex.renderToString(f.tex, { displayMode: !!f.display, macros: { ...macros } })
} catch (e) {
fehler.push(`$${f.tex.slice(0, 60)}$${String(e.message || e).slice(0, 120)}`)
}
}
process.stdout.write(JSON.stringify(fehler))

View File

@@ -16,10 +16,15 @@ const marked = new Marked(
})
)
// LaTeX-Gewohnheiten aus Vorlesungsskripten normalisieren, die KaTeX nicht
// kennt — synchron halten mit backend/katex_check.mjs
const MACROS = { '\\textsc': '\\text{#1}' }
function kx(tex, displayMode) {
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt
return katex.renderToString(tex, { displayMode }).replace(/\n/g, ' ')
return katex.renderToString(tex, { displayMode, macros: { ...MACROS } })
.replace(/\n/g, ' ')
}
function mathe(text) {

View File

@@ -17,7 +17,8 @@ Prüfe:
nicht abschreiben. Belegte/korrekte Claims NICHT auflisten.
2. COVERAGE: Fehlt ein Atom-Inhalt, sodass das Lernziel nicht erreichbar ist → art "luecke".
3. LESBARKEIT: Grobe Mängel (Gedankensprünge, unerklärte Fachbegriffe, fremdsprachige
Passagen, Aufgaben-Metadaten wie Aufgabennummern/Punktzahlen/„Zeigen Sie…") →
Passagen, Aufgaben-Metadaten wie Aufgabennummern/Punktzahlen/„Zeigen Sie…",
übernommene PDF-Artefakte wie „u 6= v" oder „V 0" statt $V'$) →
art "stil", als konkreter, behebbarer Auftrag. Geschmack ist kein Befund.
4. SELBSTSTÄNDIGKEIT: Der Text verweist auf seine Quellen („laut/gemäß Beleg",
„im Skript", „aus der Hausaufgabe/Präsenzaufgabe bekannt", „die Quelle

View File

@@ -40,7 +40,13 @@ Regeln (hart):
ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
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$`.
Klartext (`x_i`, `2^(n)`), sondern `$x_i$`, `$2^n$`. Auch Beträge: `$|C^*|$`,
nie `|C*|` im Fließtext (der Stern startet sonst Markdown-Kursivschrift).
Nur KaTeX-STANDARDBEFEHLE — kein LaTeX-Textsatz wie `\textsc`, `\usepackage`.
Ein Parse-Gate prüft jede Formel mit dem echten Renderer.
Hervorhebung im Text NUR als `**fett**` — nie einzelne `*…*`.
PDF-Artefakte aus den Belegen in saubere Notation übersetzen: „u 6= v" ist
$u \neq v$, „V 0" ist $V'$ — du lehrst, du zitierst nicht.
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
ein, kein Fülltext, keine Wiederholungen.

View File

@@ -187,3 +187,19 @@ def test_det_auftraege_blockquote_und_artefakt():
assert "Blockquote" in text and "6=" in text
assert "ungerade Anzahl $-Zeichen" in text
assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen
def test_katex_gate_und_markdown_hygiene(monkeypatch):
import guide
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
# Parse-Gate: echte KaTeX-Prüfung (nutzt node + frontend/node_modules)
fehler = guide._katex_fehler([{"tex": "\\undefinedmacro{x}", "display": False},
{"tex": "x^2", "display": False},
{"tex": "\\textsc{Subset Sum}", "display": False}])
assert len(fehler) == 1 and "undefinedmacro" in fehler[0] # \textsc ist per Makro ok
# Markdown-Hygiene: Beträge, Einzel-Stern, literales \n
auftraege = " ".join(guide._mathe_auftraege(
"Es gilt |C*| und *wichtig* laut Text.\\n- Punkt. Aber $\\neq$ bleibt ok.", "Test"))
assert "Beträge" in auftraege
assert "einzelnes „*“" in auftraege
assert "literales" in auftraege