update
This commit is contained in:
@@ -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
|
||||
|
||||
43
Makefile
43
Makefile
@@ -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"
|
||||
|
||||
@@ -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
28
backend/katex_check.mjs
Normal 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))
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user