This commit is contained in:
team3
2026-07-10 16:17:29 +02:00
parent 0a41166cca
commit 718c18523d
10 changed files with 297 additions and 16 deletions

11
.dockerignore Normal file
View 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
View 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"]

View File

@@ -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."

View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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

View File

@@ -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.

View File

@@ -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