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
|
&& 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
|
||||||
|
|||||||
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:
|
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."
|
|
||||||
|
|||||||
@@ -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
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) {
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user