From 718c18523d59f3fe393c7d756a6761c75578b47c Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 10 Jul 2026 16:17:29 +0200 Subject: [PATCH] update --- .dockerignore | 11 ++++ Dockerfile | 46 ++++++++++++++ Makefile | 38 ++++++++++++ backend/guide.py | 43 ++++++++----- backend/main.py | 5 ++ backend/requirements.txt | 7 +++ dev-ops/opencode.json | 125 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 30 +++++++++ templates/Guide-Writer.md | 4 +- tests/test_bausteine.py | 4 +- 10 files changed, 297 insertions(+), 16 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backend/requirements.txt create mode 100644 dev-ops/opencode.json create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59a3920 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +node_modules +frontend/node_modules +frontend/dist +storage +topics +.claude-data +.env +__pycache__ +**/__pycache__ +.pytest_cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ec94f0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index b8a00df..a341724 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,41 @@ 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." diff --git a/backend/guide.py b/backend/guide.py index 00454c9..8193d11 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -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] -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.""" atome = _atome_von(b["id"]) 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: auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —" " durch $\\neq$ ersetzen.") - for nr, absatz in enumerate(lang.split("\n\n"), 1): - if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel - auftraege.append(f"Absatz {nr}: ungerade Anzahl $-Zeichen — jede Formel" - 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.") + auftraege += _mathe_auftraege(lang, "Langtext") + if kompakt: + auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung") 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"],)) atome = _atome_von(b["id"]) 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" + "\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", diff --git a/backend/main.py b/backend/main.py index b97ca33..70d72b0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -154,6 +154,11 @@ def state(topic: str): } +@app.get("/api/health") +def health(): + return {"ok": True} + + @app.get("/api/topics/{topic}/guide") def guide_holen(topic: str): # kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4e684dc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +# Runtime-Abhängigkeiten (torch kommt im Dockerfile als CPU-Build davor) +fastapi +uvicorn[standard] +httpx +ftfy +fuzzysearch +sentence-transformers diff --git a/dev-ops/opencode.json b/dev-ops/opencode.json new file mode 100644 index 0000000..e1d5c55 --- /dev/null +++ b/dev-ops/opencode.json @@ -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 + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b2ed8d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/templates/Guide-Writer.md b/templates/Guide-Writer.md index bb1a199..ea99afa 100644 --- a/templates/Guide-Writer.md +++ b/templates/Guide-Writer.md @@ -38,7 +38,9 @@ Regeln (hart): 4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$` (z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle 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 Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel ein, kein Fülltext, keine Wiederholungen. diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index e5aa52b..32ec220 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -181,7 +181,9 @@ def test_det_auftraege_blockquote_und_artefakt(): lang = (f"\n" + "Fließtext. " * 45 + "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v." + "\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) 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