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 && pip install --no-cache-dir -r /app/backend/requirements.txt
COPY --chown=app:app backend/ /app/backend/ 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 templates/ /app/templates/
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist 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 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: install:
pip install --break-system-packages fastapi "uvicorn[standard]" httpx pytest pytest-asyncio ftfy fuzzysearch pip install --break-system-packages fastapi "uvicorn[standard]" httpx pytest pytest-asyncio ftfy fuzzysearch
@@ -26,40 +26,7 @@ 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) ──── # ── Produktion (auf dem Server ausführen, im Projektverzeichnis) ──────────────
SERVER = root@178.104.67.87 prod:
REMOTE = /var/www/creator2 docker compose up -d --build
@echo "creator2 läuft: https://creator2.marha.de"
# 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."

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.""" Lektion 52); deterministische Checks laufen davor im Code."""
import asyncio import asyncio
import json
import logging import logging
import os
import re import re
import subprocess
from pathlib import Path
import db import db
import llm import llm
@@ -17,6 +21,33 @@ from config import SECTION_WOERTER_PRO_ATOM
log = logging.getLogger("creator2.guide") 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" EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|") _MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
@@ -125,6 +156,24 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
if reste: if reste:
auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):" auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):"
f" auch Indizes/Potenzen gehören in $…$ (x_i → $x_i$).") 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 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) { function kx(tex, displayMode) {
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown- // Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt // Ü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) { function mathe(text) {

View File

@@ -17,7 +17,8 @@ Prüfe:
nicht abschreiben. Belegte/korrekte Claims NICHT auflisten. nicht abschreiben. Belegte/korrekte Claims NICHT auflisten.
2. COVERAGE: Fehlt ein Atom-Inhalt, sodass das Lernziel nicht erreichbar ist → art "luecke". 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 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. art "stil", als konkreter, behebbarer Auftrag. Geschmack ist kein Befund.
4. SELBSTSTÄNDIGKEIT: Der Text verweist auf seine Quellen („laut/gemäß Beleg", 4. SELBSTSTÄNDIGKEIT: Der Text verweist auf seine Quellen („laut/gemäß Beleg",
„im Skript", „aus der Hausaufgabe/Präsenzaufgabe bekannt", „die Quelle „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 ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
IMMER umschließende `$…$`) — ohne Delimiter rendert nichts. Das gilt für BEIDE IMMER umschließende `$…$`) — ohne Delimiter rendert nichts. Das gilt für BEIDE
Fassungen, auch die Stichpunkte in „kompakt": Indizes und Potenzen nie als 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 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.

View File

@@ -187,3 +187,19 @@ def test_det_auftraege_blockquote_und_artefakt():
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 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